From 2581b216c611a1071a03a15970c78e0a2a15010d Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 06:21:21 +0200 Subject: [PATCH 01/11] feat(mail): mail-relay, mailserver and webmail modules Stage 5 of lss 2.0 adds the mail group. Servers need to send mail (the guard's alerts, cron, applications), and the office profile needs a real mail server with webmail, hardened the same way as every other module. - mail-relay: Postfix as a send-only relay on the loopback addresses, through a smarthost with TLS (certificate checked) and login, or directly with opportunistic TLS or DANE. Mail for root stays in /var/mail/root or goes to a forward address; no delivery to programs, files or ~/.forward. - mailserver: Postfix (SMTP; submission on 587 and 465 with TLS and login only, senders tied to their login), Dovecot 2.3 or 2.4 (IMAPS, passwords in the lss secrets), Rspamd from its official repositories (spam filter, DKIM signing, SPF/DKIM/DMARC checks). lss cannot set DNS, so it prints the MX, A/AAAA, SPF, DKIM, DMARC and PTR records and keeps them in /etc/lss/mailserver-dns.txt. verify sends a message through the whole server and checks the DKIM signature and the spam filter (GTUBE). - webmail: Roundcube 1.7.4, checksum-pinned, as an HTTPS site of the web server, talking to Dovecot and Postfix on 127.0.0.1 only. - lib/mail.sh holds what both Postfix modules share; every service runs in the systemd sandbox of svc_sandbox. - nginx PHP sites pass PATH_INFO (Roundcube serves its assets through static.php/...); the PHP-FPM pool helpers move from phpmyadmin to the web layer; the tls deploy hook reloads Postfix and Dovecot; restic backs up mailboxes, DKIM keys and the webmail database by default. - guard warns when LSS_GUARD_EMAIL is set but nothing can send mail. - tests: unit tests in tests/unit/mail.bats; the integration job "mail" on every system runs tests/integration/mail-flow.sh: mail between two mailboxes, DKIM/SPF/DMARC checked against the printed records through Rspamd's fake DNS, GTUBE rejected, refused logins and relaying, a Roundcube login, and the guard's summary delivered through mail-relay. - profiles: new office profile; all gains mailserver and webmail. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 7 +- data/checksums.txt | 3 + data/keys/rspamd.asc | 65 +++ data/repos/rspamd.conf | 17 + lib/mail.sh | 182 +++++++ lib/step.sh | 2 +- lib/web.sh | 44 +- modules/backup/restic.sh | 2 +- modules/database/phpmyadmin.sh | 33 +- modules/mail/mail-relay.sh | 224 ++++++++ modules/mail/mailserver.sh | 850 ++++++++++++++++++++++++++++++ modules/mail/webmail.sh | 249 +++++++++ modules/security/guard.sh | 12 +- modules/web/tls.sh | 5 +- profiles/all.conf | 4 +- profiles/office.conf | 11 + tests/integration/mail-flow.sh | 235 +++++++++ tests/integration/run.sh | 15 + tests/integration/settings.conf | 10 + tests/unit/helpers.bash | 2 +- tests/unit/mail.bats | 336 ++++++++++++ tests/unit/web.bats | 3 + 22 files changed, 2271 insertions(+), 40 deletions(-) create mode 100644 data/keys/rspamd.asc create mode 100644 data/repos/rspamd.conf create mode 100644 lib/mail.sh create mode 100644 modules/mail/mail-relay.sh create mode 100644 modules/mail/mailserver.sh create mode 100644 modules/mail/webmail.sh create mode 100644 profiles/office.conf create mode 100755 tests/integration/mail-flow.sh create mode 100644 tests/unit/mail.bats diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index b2c0c7a..537ef9b 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,7 +31,7 @@ jobs: fail-fast: false matrix: system: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04, ubuntu-26.04, rocky-9, almalinux-10] - group: [base, security, scanners, web, runtime, database, database-alt, container, backup, backup-alt, monitoring, network] + group: [base, security, scanners, web, runtime, database, database-alt, container, backup, backup-alt, monitoring, network, mail] arch: [x64, arm64] include: - group: base @@ -66,6 +66,11 @@ jobs: modules: node-exporter prometheus,alertmanager,grafana nginx,prometheus,grafana loki,alloy wireguard,netdata smartd - group: network modules: wireguard dns-resolver ntp-server + # mail-relay alone, then with the guard, whose daily summary it delivers to root; the mail server, then with + # webmail behind nginx. tests/integration/mail-flow.sh sends, receives and checks DKIM, SPF, DMARC and the spam + # filter without public DNS. + - group: mail + modules: mail-relay guard,mail-relay mailserver nginx,php,mailserver,webmail steps: - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: diff --git a/data/checksums.txt b/data/checksums.txt index 8b59d66..5c5f5be 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -18,6 +18,9 @@ phpmyadmin-5.2.3 any 57881348297c4412f86c410547cf76b4d8a236574dd2c6b7d6a2beebe7f # Adminer 6.1.2: SHA-256 digest of the release asset on GitHub (gh api repos/vrana/adminer/releases/tags/v6.1.2, # checked 2026-10-13). adminer-6.1.2 any b32316156bd18ffa99c1a27617142c2e5ad72260e37f15da4e9937a3dbb15f57 https://github.com/vrana/adminer/releases/download/v6.1.2/adminer-6.1.2.php +# Roundcube 1.7.4 (complete package with its PHP libraries): SHA-256 digest of the release asset on GitHub (gh api +# repos/roundcube/roundcubemail/releases/tags/1.7.4, checked 2026-10-15; the download matched it). +roundcube-1.7.4 any 2c6c878f0093f1bf7fb6086781d2dd9269d652c016b86939c157c5f1729139a2 https://github.com/roundcube/roundcubemail/releases/download/1.7.4/roundcubemail-1.7.4-complete.tar.gz # pgAdmin 4 9.18 and gunicorn 26.2.0: SHA-256 of the wheels as published by PyPI (https://pypi.org/pypi/pgadmin4/9.18/json, # https://pypi.org/pypi/gunicorn/26.2.0/json, checked 2026-10-13). pgadmin4-9.18 any 9ea084fc416265e4fc95c9fb6e6ae0f6668d34da8670c9b65c79cea054124356 https://files.pythonhosted.org/packages/0e/80/86d7e7123e2b6de41890bd1edc3e27348e2572acea337088faa0ec41c7f5/pgadmin4-9.18-py3-none-any.whl diff --git a/data/keys/rspamd.asc b/data/keys/rspamd.asc new file mode 100644 index 0000000..1fcfb15 --- /dev/null +++ b/data/keys/rspamd.asc @@ -0,0 +1,65 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFW3VB8BEADAV1lBy8DPcSEBSLYVKgwsBx/dRmgenKeliMpiZyNYJJmF6tSV +s3v5DtDIUESgI2mBKNeptdneri3CDJScI/LgPLKqemrLBkAMfe+f57JgppY5ti4H +xo+VZdbF9bhCAwYwJnqnyuLjYSUu6nCuW4uPDoqBHXynwsIWr1O3fREpY+vgIgaT +Oqm3ncssqxSicymd6k0yuo55xuUvrc4Yu4IEnhFVRU53e0E3zmHg/7ONI99YtBan +7G/w2IfA1bfRDYZ2Avau+JqGcEl8vy+eLmYayKirdsMPN8Tx6RFOstDf1CnjW/bj +IX7SDOklIGJjJwcWW/iY+1P9SfNNqSDgXavJj2wmLMlUhgjyJFTXfdDRjmN0PFxo +f6OQu5xok1WHfKFJL+hLGknjHdXLmGd5MSuFlutdVHJQrieknjBea9xCiEsrfe8V +zyNqGhzgIYjOi/bO7jGpY/WiFHvM9XtBVp862tqM1S1WbAWW5u+es6NK4q9Cv0DR +tIalss+5gFhdsIFGFYQWfY7CrjOIC+C0+c5IGaBkHte35hCCvDpOO909xxVqUZYe +9Pl8zYgPDe1H4arMO+p6rSvVntvIWOqLqkuWYSiOY4TGADJTkeZRbopZhvqs/9mc +847fVMbOwKfkbeuGiHhUK0QFewXSu+cXJyGtyu3RgokBWr2yyzJFXIvJbQARAQAB +tDxWc2V2b2xvZCBTdGFraG92IChSc3BhbWQgcGFja2FnZXMga2V5KSA8dnNldm9s +b2RAcnNwYW1kLmNvbT6JAk4EEwEKADgCGwMCHgUCF4AFCwkIBwMFFQoJCAsFFgID +AQAWIQQ/o0fV5Zm+RZXKJXb/ojLtvyHiXgUCZRrQ/AAKCRD/ojLtvyHiXjNXD/0Z +9E03yzFU6IIMwxr4++ZaOPyRnel6UoRpmQErAzQukaMaSOUP0uAEdfZ5TVOZAydJ +kGwXefk15pgYdXNJ8uznomfBEvgimQKqpOxt7QNm8MbBnXJijwxCflJ3FvlQvxF9 +5iu4nS9wZ/pJjw66eTgiv9UY3k1LT88VXZPKURoqwmb4FuZO6WBI5XBp1Wl51V2h +IorWxvVtoolEPuucpyTIKU1Tdp3oMDy/caF3/YIP+Y0e7DGO8YDxgfOhJwTY2oqw +uTTveVFhtnVPbCEqzv4D66Ukosd97zeRGoQ9kh5RKoAitnc0nmLpYbp3IfbN1bO/ +zZ/AgwjVORsRTT8rFhA1NeuixicHkj5/U3w14vqICbM1eOA668fRZ0duVi1KFVu9 +9zEanf55l5AhDd5+XTEtbxEMv+c797ihhldK1RSfhNpf1rmN1dqHlGzt1seRzyEp +yafCfylsppMnEPlfXJBSaBTp9DV1H+u0HQTjMh8gkiLRa7UYxsB07xUgdnKqEm2I +isty2pKvTaAQsP2rcm4RRX6pQ53Uv/VEGtktVRSJcuCSfDSKxFeKJDNtjjqqpYXU +hQp+JYN9qzXTGeQ9L/2zOvQhs44OeX/L+EQ7yAT3qzGNYkULauJ/4JnAddhFkB1p +RSgBS7Sk88+A7eHLfI/E7BuYns0q/L8e6nV+yyd+yrRGUnNwYW1kIE5pZ2h0bHkg +QnVpbGRzIChSc3BhbWQgTmlnaHRseSBCdWlsZHMpIDx2c2V2b2xvZEBoaWdoc2Vj +dXJlLnJ1PokCNgQwAQoAIBYhBD+jR9Xlmb5Flcoldv+iMu2/IeJeBQJkFDh8Ah0g +AAoJEP+iMu2/IeJegX8P+wXWeXTEhpycLIYK4ifUbXeCkBfRc/8NwxnOjoC+VDNP +B76unqvOtpLLlyHndufp5FcxdgtFurXGaciKh6FUwTUtO3L3Is1UxdJlTlFxPSE4 +yoxB6fQiv8ATX3f2YyofkhwSn+bU8JJ7N6fJeQhtEeZ2Qv7Ct/hC3dhsd8PH8cK7 +zQHui4sNB51rJJZEZCOqlj3O3GRwsIrSaYe2xr7j0tKQ18/BYVs5B3eJKA6XYmKK +bYjXGuwo3LvIMdTsKHHGjzCDAXhi3yh8F4NA97DEfWOI8/eAfkJPdN56W62uxPAG +wxYdL+ZE+Jwmdl/5+YCj0BAuPLHOuGv3mtP4zigfqe7yMWSgZLacqDNZv4n9U6Yu +DHKie6CL+v0CdPwvcFyAgvST5HyRQAgOENhbdGRPX8y892dTaMrOPubVmY24i0pT +kP80mhItGS51zvXqydCaSuo7h8fNFmyuFaclniMlye5A8wEVEiq8pL/dZwGB0YfZ +Mx/WvaMfp4vowswmLQ3P23nHFFUhdZozIkcm+JdUNkejxW22YKs/yTqSh9hNbh74 +cc7pfWHLG02/fxckHSJ/XzIuAK7sDeijQM6D4yXR4EB6VoAbMlQblQ32Y6vo76P7 +wEz5xjBmG0y2LMTxbF0ReDi9mAJw3nKq9Vd8q62iDEihlSGxxHbH6Cb8DPHkC8X/ +uQINBFW3VB8BEAC8WJVOYP0mv8vrh7aqr3YtwdCeGkBL7BvkZe/ZG/JI6zehrIxI +2I/lwsyNCtG7bipjJrgftA2lru+VFR1yqMFc9z1s80HYTXeAZodmtCUkUBwjQw3L +dWfa/9UQzpl96Ly6HamscDF8566KzMaNVp8geh5M3ZXHr5KdOgHwSDj7HgncCqLo +2iv0cNcE9nh3GjZXrjFdoajRdeD8Qclletp8pGNQtb/TOT/wKvDM+iJW3gRrCMx4 +npTM5jPypfS9Ej2CJys6dppQi+5qCnZPQhm86Qzu33RTqQ+/5Hz5uuJpaT3igHJM +nJTW9WJbwYdTW7oCcrRmYapWKUlt634Z4A5aAVTq1w4es+JN8J0ahGkzxt41mesw +H3Un6OPR+hooLpCC6bBFwe52rrZ5Bdf/hIBF2EUYfrybzqmuw3aEtz9JdDUeSwVZ +2CHpuWjR6WgoKpjF2YQRWEhxt213XHcGOQObI8/N+8RnRs4NWUzfJmwNsL2rWt4U +H/YL3aAMGje4yXzLzmIcTM9NTGqZXCmer0IBym65KOlerija/XQav5Crpo5HTTCC +vxGkZa6lhTzwuoHLt6rnj8nFvt3xMcyXa/uTMAekZBexAyATW0s4RL86WgN/Xex8 +kmRZjHmTpYjSXyKxOMnW2KIXAOqRLqPNIPFkL6n/lb9nYgZK3NMinC7/2QARAQAB +iQI2BBgBCgAgAhsMFiEEP6NH1eWZvkWVyiV2/6Iy7b8h4l4FAmUa0OIACgkQ/6Iy +7b8h4l54UQ/+LUNTAs7TUq94TdEOj/2TnvUcJzsm+MKA6Xzb/XqaTacagO2H5R4i +m774yeTFqXXWIis2npauOdlLSbP+vTLYbeMaTaxheHlM1a+BVSUFyW0KBnqN7dyD +vBPozb1hh1MeQwtLtUnIH7uqJZfW2Mg9zFdrLnP9IwMrV/CBabdaGhJyA3d1FgyX +M08yVcYn0Lfyf0p7WZnzyKbMuiKq3mzYSHA/+3kpLMIF5L5qF6o5bJe3a+EL717a +VzF4WtDen3UDl950O0kpCXMqd5YWfHrb9WEthxZk/MCxxmrI2GdyfF2TcLnvM0Ek +WMD8+ybq9aBC5vjzhO+uramnnT2aeeboVi0PMvqqI9+wEffSe03lMHlrQwWruOka +tQvwKt0YebSp/rBMuw+S5n17ufPBKsjObemiVDjqLSxC7omMGGpkGlBLXgT1gH6l +pvM/pgCZNvyP4VCiZlY3W3eWuOoNalwRJs9TGx6rh7mWsBbsQvqhtdE2/bbVjl/M +M4YwPEGYVVtL47XOe6cO4+kisGBhuoohI+L3fAGiiGP4v7lM9QkTSC6a2LDDUkPu +tVOaAHu9qAfDqq5g+ZJ9bZCOH37YVebG6gXEyaU1TryK8H7Lcnxb7l/lLXOHCQ4A +l6reGsikdBNPekniogzpbSBM4otWXIGXRjwZNgTWqal4g4hCnKA1jQI= +=Zuyq +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/rspamd.conf b/data/repos/rspamd.conf new file mode 100644 index 0000000..dc89f10 --- /dev/null +++ b/data/repos/rspamd.conf @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Rspamd from its official repositories (https://rspamd.com/downloads.html): one apt suite per release for Debian and +# Ubuntu, centos-9 and centos-10 builds for the RHEL family, x86_64 and aarch64 each. The distributions package no +# Rspamd for the RHEL family and older ones for Debian and Ubuntu; this way every system runs the same version. Key +# https://rspamd.com/apt-stable/gpg.key (the same as https://rspamd.com/rpm/rspamd.asc), fetched on 2026-10-15. +# Rspamd signs the RPM packages and the metadata. The RPM packages need EPEL (libsodium, hyperscan, jemalloc). +KEY=rspamd.asc +FINGERPRINTS=3FA347D5E599BE4595CA2576FFA232EDBF21E25E +DEB_URL=https://rspamd.com/apt-stable/ +DEB_SUITE={{codename}} +DEB_COMPONENTS=main +RPM_BASEURL=https://rspamd.com/rpm-stable/centos-{{major}}/$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/lib/mail.sh b/lib/mail.sh new file mode 100644 index 0000000..457de3d --- /dev/null +++ b/lib/mail.sh @@ -0,0 +1,182 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Mail layer shared by mail-relay and mailserver: Postfix (its configuration, unit and systemd sandbox), the host name +# mail leaves with, a TLS certificate for it, and the local delivery test both modules verify with. The two modules +# conflict: each one owns the whole Postfix configuration. +# +# Lookup tables are texthash: files Postfix reads directly, so no postmap run and no database format (Berkeley DB is +# gone on the RHEL 10 family). Postfix reads them when a daemon starts; mail_postfix_apply reloads after a change. + +MAIL_POSTFIX_DIR=/etc/postfix +MAIL_POSTFIX_MAIN=${MAIL_POSTFIX_DIR}/main.cf +MAIL_POSTFIX_MASTER=${MAIL_POSTFIX_DIR}/master.cf +# The self-signed certificate of the mail host name, next to the certificates of the web layer. +MAIL_TLS_DIR=/etc/lss/tls/mail +# Mail to this local part is discarded (transport discard:); verify sends one message to it through the whole queue. +# shellcheck disable=SC2034 # read by the mail modules +MAIL_VERIFY_LOCALPART=lss-verify + +# mail_fqdn [NAME]: NAME, or the server's full host name; a name without a domain gets .localdomain, because Postfix +# refuses a host name without a dot. +mail_fqdn() { + local name=${1:-} + if [[ -z ${name} ]]; then + name=$(hostname -f 2>/dev/null || true) + [[ -n ${name} ]] || name=$(hostname) + fi + name=${name,,} + [[ ${name} == *.* ]] || name+=.localdomain + printf '%s' "${name}" + return +} + +# mail_valid_domain NAME: a DNS name with at least two labels (example.com, mail.example.org). +mail_valid_domain() { + [[ $1 =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$ ]] + return +} + +# mail_postfix_unit: the unit that runs the Postfix master. Debian and Ubuntu start it as the instance postfix@-; +# postfix.service there only groups the instances. +mail_postfix_unit() { + if os_is_debian; then printf 'postfix@-'; else printf 'postfix'; fi + return +} + +# mail_postfix_install: Postfix without the configuration questions of the Debian package (lss writes main.cf). +mail_postfix_install() { + if os_is_debian && ! pkg_is_installed postfix; then + printf 'postfix postfix/main_mailer_type select No configuration\n' | debconf-set-selections + fi + pkg_install postfix + return +} + +# mail_ca_bundle: the CA certificates of the system, for checking the certificates of other mail servers. +mail_ca_bundle() { + if os_is_debian; then printf '/etc/ssl/certs/ca-certificates.crt'; else printf '/etc/pki/tls/certs/ca-bundle.crt'; fi + return +} + +# mail_inet_protocols: all where the kernel has IPv6, ipv4 otherwise (kernel-hardening can switch IPv6 off). +mail_inet_protocols() { + if [[ -f /proc/net/if_inet6 ]]; then printf 'all'; else printf 'ipv4'; fi + return +} + +# mail_master_cf: the internal services of master.cf. The caller puts its listeners in front of it. Nothing runs +# chrooted: the systemd sandbox (mail_postfix_sandbox) confines the whole Postfix instead. +mail_master_cf() { + cat <<'EOF' +pickup unix n - n 60 1 pickup +cleanup unix n - n - 0 cleanup +qmgr unix n - n 300 1 qmgr +tlsmgr unix - - n 1000? 1 tlsmgr +rewrite unix - - n - - trivial-rewrite +bounce unix - - n - 0 bounce +defer unix - - n - 0 bounce +trace unix - - n - 0 bounce +verify unix - - n - 1 verify +flush unix n - n 1000? 0 flush +proxymap unix - - n - - proxymap +proxywrite unix - - n - 1 proxymap +smtp unix - - n - - smtp +relay unix - - n - - smtp + -o syslog_name=postfix/$service_name +showq unix n - n - - showq +error unix - - n - - error +retry unix - - n - - error +discard unix - - n - - discard +local unix - n n - - local +virtual unix - n n - - virtual +lmtp unix - - n - - lmtp +anvil unix - - n - 1 anvil +scache unix - - n - 1 scache +postlog unix-dgram n - n - 1 postlogd +EOF + return +} + +# mail_postfix_sandbox [DIRECTIVE...]: the systemd sandbox of the Postfix master and its daemons. They write the queue, +# their state and the local mailboxes only. Postfix needs /dev/log of the host for syslog, so /dev stays. +mail_postfix_sandbox() { + local unit=postfix.service + os_is_debian && unit=postfix@.service + svc_sandbox "${unit}" PrivateDevices=no ReadWritePaths=/var/spool/postfix ReadWritePaths=/var/lib/postfix \ + "$@" + return +} + +# mail_postfix_apply MAIN MASTER: writes main.cf and master.cf from the two strings, checks them with postfix check, +# starts Postfix and restarts it when the module changed anything since BEFORE (a change count, files_changes_of). +mail_postfix_apply() { + local main=$1 master=$2 before=$3 unit output + unit=$(mail_postfix_unit) + file_write 0644 root:root "${MAIL_POSTFIX_MAIN}" <<<"${main}" + file_write 0644 root:root "${MAIL_POSTFIX_MASTER}" <<<"${master}" + output=$(postfix check 2>&1) || { + printf '%s\n' "${output}" >&2 + die "${LSS_MODULE}: postfix check rejects the configuration." + } + # On Debian and Ubuntu postfix.service starts the instance; the instance itself has nothing to enable. + svc_enable postfix + if ! svc_is_active "${unit}"; then + timeout "${SVC_START_TIMEOUT}" systemctl start "${unit}" || die "${LSS_MODULE}: Postfix does not start." + files_note_change "started ${unit}" + fi + if [[ $(files_changes_of "${LSS_MODULE}") != "${before}" ]]; then + svc_restart "${unit}" || die "${LSS_MODULE}: Postfix does not start with the new configuration." + fi + return +} + +# mail_tls_cert NAME: a self-signed certificate for the host name NAME in MAIL_TLS_DIR, renewed when it expires within +# 30 days or names another host. The key is readable by root only; Postfix and Dovecot load it before they drop their +# privileges. +mail_tls_cert() { + local name=$1 tmp + if [[ -s ${MAIL_TLS_DIR}/fullchain.pem && -s ${MAIL_TLS_DIR}/privkey.pem ]] && + openssl x509 -checkend $((30 * 86400)) -noout -in "${MAIL_TLS_DIR}/fullchain.pem" >/dev/null 2>&1 && + [[ $(web_cert_domains "${MAIL_TLS_DIR}/fullchain.pem") == "${name}" ]]; then + return 0 + fi + tmp=$(mktemp -d) + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 825 -subj "/CN=${name}" \ + -addext "subjectAltName=DNS:${name}" -keyout "${tmp}/privkey.pem" -out "${tmp}/fullchain.pem" >/dev/null 2>&1 || + die "${LSS_MODULE}: could not create a self-signed certificate for ${name}." + install -d -m 0755 "${MAIL_TLS_DIR}" + install -m 0600 -o root -g root "${tmp}/privkey.pem" "${MAIL_TLS_DIR}/privkey.pem" + install -m 0644 -o root -g root "${tmp}/fullchain.pem" "${MAIL_TLS_DIR}/fullchain.pem" + rm -rf -- "${tmp}" + files_note_change "self-signed certificate for ${name}" + return +} + +# mail_queue_has TEXT: a message in the Postfix queue (any queue) names TEXT as a recipient. +mail_queue_has() { + local text=$1 + postqueue -j 2>/dev/null | grep -F "\"${text}\"" >/dev/null + return +} + +# mail_send_check SENDER RECIPIENT [SECONDS]: hands a message to the sendmail command of Postfix and waits until it +# has left the queue. That proves pickup, cleanup, the queue manager and a delivery agent work. Prints the Message-ID. +mail_send_check() { + local sender=$1 recipient=$2 seconds=${3:-60} id i + id="" + printf 'From: %s\nTo: %s\nSubject: lss verify\nMessage-ID: %s\nDate: %s\n\nTest message of lss verify; it is deleted right away.\n' \ + "${sender}" "${recipient}" "${id}" "$(LC_ALL=C date -R)" | + sendmail -i -f "${sender}" -- "${recipient}" || + die "${LSS_MODULE}: sendmail does not accept a message." + for ((i = 0; i < seconds; i++)); do + mail_queue_has "${recipient}" || { + printf '%s' "${id}" + return 0 + } + sleep 1 + done + postqueue -j >&2 || true + die "${LSS_MODULE}: a test message to ${recipient} is still in the Postfix queue after ${seconds} s." +} diff --git a/lib/step.sh b/lib/step.sh index cbf5e73..757da7a 100755 --- a/lib/step.sh +++ b/lib/step.sh @@ -15,7 +15,7 @@ set -Eeuo pipefail # change notes it sets reach the caller. shopt -s inherit_errexit lastpipe -for library in log os config files template secrets state pkg repo fetch service firewall container web monitoring module; do +for library in log os config files template secrets state pkg repo fetch service firewall container web mail monitoring module; do # shellcheck source=/dev/null source "${LSS_ROOT}/lib/${library}.sh" done diff --git a/lib/web.sh b/lib/web.sh index 4dbb158..0a07392 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -339,10 +339,12 @@ EOF location / { try_files \$uri \$uri/ /index.php?\$query_string; } - location ~ \\.php\$ { + location ~ [^/]\\.php(/|\$) { + fastcgi_split_path_info ^(.+?\\.php)(/.*)\$; try_files \$fastcgi_script_name =404; include fastcgi_params; fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name; + fastcgi_param PATH_INFO \$fastcgi_path_info; fastcgi_param HTTPS on; fastcgi_pass unix:${WEB_PHP_SOCKET}; } @@ -652,6 +654,46 @@ web_admin_remove() { return } +# web_php_fpm_units: the PHP-FPM units of the php module (php8.x-fpm on Debian and Ubuntu, php-fpm on the RHEL family). +web_php_fpm_units() { + systemctl list-units --type=service --all --no-legend --plain '*php*fpm*' 2>/dev/null | awk '{ print $1 }' + return +} + +# _web_php_pool_value KEY: a setting (user, group) of the PHP-FPM pool on WEB_PHP_SOCKET, the php module's pool. +_web_php_pool_value() { + local key=$1 file + for file in /etc/php/*/fpm/pool.d/*.conf /etc/php-fpm.d/*.conf; do + [[ -f ${file} ]] || continue + grep -qE "^[[:space:]]*listen[[:space:]]*=[[:space:]]*${WEB_PHP_SOCKET}[[:space:]]*\$" "${file}" || continue + sed -nE "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*([A-Za-z0-9_.-]+).*/\\1/p" "${file}" | head -n 1 + return 0 + done + return 0 +} + +# web_php_pool_user: the user the PHP-FPM workers of the php module's pool run as, or nothing. +web_php_pool_user() { + _web_php_pool_value user + return +} + +# web_php_pool_group: the group of the PHP-FPM pool on WEB_PHP_SOCKET, which reads the configuration of PHP +# applications; WEB_GROUP when the pool configuration does not name one. +web_php_pool_group() { + local pool_user pool_group + pool_user=$(_web_php_pool_value user) + pool_group=$(_web_php_pool_value group) + if [[ -z ${pool_group} && -n ${pool_user} ]]; then + pool_group=$(id -gn "${pool_user}" 2>/dev/null || true) + fi + if [[ -z ${pool_group} ]] || ! getent group "${pool_group}" >/dev/null; then + pool_group=${WEB_GROUP} + fi + printf '%s' "${pool_group}" + return +} + # web_admin_htpasswd SERVER: the basic-auth file for a public admin server, user "admin" with the secret # web-admin-password. SHA-512 crypt for nginx and apache, bcrypt for caddy in its own format (WEB_ADMIN_CADDY_AUTH). web_admin_htpasswd() { diff --git a/modules/backup/restic.sh b/modules/backup/restic.sh index 14f9261..185dff5 100644 --- a/modules/backup/restic.sh +++ b/modules/backup/restic.sh @@ -7,7 +7,7 @@ MOD_GROUP="backup" MOD_DESCRIPTION="Encrypted, deduplicated daily backups with restic to a local path, SFTP or S3, with retention, repository checks and a weekly restore test (guard)" MOD_REQUIRES="system" MOD_OPTIONS="repository|/var/backups/lss-restic|Where the backups go: a local path (better another disk), sftp:USER@HOST:/PATH or s3:https://HOST/BUCKET/PATH -paths|/etc /root /home /srv /opt /usr/local /var/www /var/spool/cron /var/lib/lss /var/backups/lss-db|What to back up, separated by spaces; paths that do not exist are skipped +paths|/etc /root /home /srv /opt /usr/local /var/www /var/spool/cron /var/lib/lss /var/backups/lss-db /var/vmail /var/lib/rspamd/dkim /var/lib/php/lss-roundcube|What to back up, separated by spaces; paths that do not exist are skipped exclude|/var/lib/lss/runs|Paths or patterns restic leaves out, separated by spaces (restic --exclude) time|03:00|Time of the daily backup (HH:MM) keep_daily|7|Daily snapshots to keep diff --git a/modules/database/phpmyadmin.sh b/modules/database/phpmyadmin.sh index ccfadfb..8c3d9fc 100644 --- a/modules/database/phpmyadmin.sh +++ b/modules/database/phpmyadmin.sh @@ -67,7 +67,7 @@ mod_configure() { local secret group port timeout allow_root=false unit _phpmyadmin_private_tmp secret_load phpmyadmin-blowfish secret 32 - group=$(_phpmyadmin_fpm_group) + group=$(web_php_pool_group) port=$(opt_int port) timeout=$(opt_int login_timeout) opt_bool allow_root && allow_root=true @@ -115,17 +115,11 @@ EOF if [[ -n ${unit} ]] && svc_is_active "${unit}"; then svc_reload "${unit}" || die "phpmyadmin: ${unit} does not reload." fi - done < <(_phpmyadmin_fpm_units) + done < <(web_php_fpm_units) fi return } -# The PHP-FPM units of the php module (php8.x-fpm on Debian and Ubuntu, php-fpm on the RHEL family). -_phpmyadmin_fpm_units() { - systemctl list-units --type=service --all --no-legend --plain '*php*fpm*' 2>/dev/null | awk '{ print $1 }' - return -} - # phpMyAdmin compiles its templates into PHP files below TempDir and includes them. Inside the private /tmp of the # PHP-FPM service (svc_sandbox sets PrivateTmp) nobody else can plant such a file; a shared /tmp is refused. _phpmyadmin_private_tmp() { @@ -137,34 +131,13 @@ _phpmyadmin_private_tmp() { yes | disconnected) ;; *) die "phpmyadmin: ${unit} shares /tmp with other services; phpMyAdmin needs the private /tmp of the php module's sandbox (lss install php)." ;; esac - done < <(_phpmyadmin_fpm_units) + done < <(web_php_fpm_units) if [[ ${found} == 0 ]]; then log_warn "phpmyadmin: no PHP-FPM service found; phpMyAdmin needs the php module." fi return } -# _phpmyadmin_fpm_group: the group of the PHP-FPM pool on WEB_PHP_SOCKET, which has to read config.inc.php; WEB_GROUP -# when the pool configuration does not name one. -_phpmyadmin_fpm_group() { - local file pool_user='' pool_group='' - for file in /etc/php/*/fpm/pool.d/*.conf /etc/php-fpm.d/*.conf; do - [[ -f ${file} ]] || continue - grep -qE "^[[:space:]]*listen[[:space:]]*=[[:space:]]*${WEB_PHP_SOCKET}[[:space:]]*\$" "${file}" || continue - pool_user=$(sed -nE 's/^[[:space:]]*user[[:space:]]*=[[:space:]]*([A-Za-z0-9_.-]+).*/\1/p' "${file}" | head -n 1) - pool_group=$(sed -nE 's/^[[:space:]]*group[[:space:]]*=[[:space:]]*([A-Za-z0-9_.-]+).*/\1/p' "${file}" | head -n 1) - break - done - if [[ -z ${pool_group} && -n ${pool_user} ]]; then - pool_group=$(id -gn "${pool_user}" 2>/dev/null || true) - fi - if [[ -z ${pool_group} ]] || ! getent group "${pool_group}" >/dev/null; then - pool_group=${WEB_GROUP} - fi - printf '%s' "${pool_group}" - return -} - # With SELinux enforcing, PHP-FPM (httpd_t) may only connect to database ports once httpd_can_network_connect_db is # on. The boolean stays on after a removal; Adminer and other PHP applications need it as well. _phpmyadmin_selinux_db() { diff --git a/modules/mail/mail-relay.sh b/modules/mail/mail-relay.sh new file mode 100644 index 0000000..e878f19 --- /dev/null +++ b/modules/mail/mail-relay.sh @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="mail-relay" +MOD_GROUP="mail" +MOD_DESCRIPTION="Postfix as a send-only relay on 127.0.0.1: mail of the server (guard, cron, applications) goes out through a smarthost with TLS and login, or directly" +MOD_REQUIRES="system" +MOD_CONFLICTS="mailserver" +MOD_OPTIONS="relayhost||Smarthost as HOST or HOST:PORT (587: STARTTLS, 465: TLS from the start); empty delivers straight to the recipients' mail servers, which needs outgoing port 25 +user||Login name at the smarthost +password||Password at the smarthost, stored in the lss secrets (mail-relay-password) +tls_verify|true|Check the certificate of the smarthost against the CA certificates of the system; false only encrypts +from||Sender address of mail from this server (root, cron, the guard); empty keeps USER@HOSTNAME, which smarthosts often refuse +forward||Address that receives mail for root and the other system accounts; empty keeps it on the server in /var/mail/root +hostname||Host name in the greeting and in local addresses; empty: the full host name of the server (with .localdomain when it has no domain)" +MOD_SECRETS="mail-relay-password" +MOD_PORTS="25/tcp on 127.0.0.1 and ::1 only (outgoing SMTP to the smarthost or the recipients)" + +RELAY_ALIASES=/etc/postfix/lss-aliases +RELAY_SASL=/etc/postfix/lss-sasl-passwd +RELAY_GENERIC=/etc/postfix/lss-generic + +_relay_address_ok() { + [[ $1 =~ ^[A-Za-z0-9._%+-]+@([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$ ]] + return +} + +mod_check() { + local relayhost host port address hostname + relayhost=$(opt relayhost) + if [[ -n ${relayhost} ]]; then + host=${relayhost%:*} + port=25 + [[ ${relayhost} != *:* ]] || port=${relayhost##*:} + mail_valid_domain "${host}" || die "LSS_MAIL_RELAY_RELAYHOST: '${host}' is not a host name like smtp.example.com." + if [[ ! ${port} =~ ^[0-9]{1,5}$ ]] || ((port < 1 || port > 65535)); then + die "LSS_MAIL_RELAY_RELAYHOST: '${port}' is not a port number." + fi + fi + if [[ -n $(opt user) || -n $(opt password) ]]; then + [[ -n ${relayhost} ]] || die "LSS_MAIL_RELAY_USER and LSS_MAIL_RELAY_PASSWORD need LSS_MAIL_RELAY_RELAYHOST." + [[ -n $(opt user) ]] || die "LSS_MAIL_RELAY_PASSWORD needs LSS_MAIL_RELAY_USER." + [[ $(opt user) =~ ^[^[:space:]]+$ ]] || die "LSS_MAIL_RELAY_USER must not contain spaces." + fi + [[ $(opt password) != *[[:space:]]* ]] || die "LSS_MAIL_RELAY_PASSWORD must not contain spaces." + opt_bool tls_verify || true + for address in "$(opt from)" "$(opt forward)"; do + [[ -z ${address} ]] || _relay_address_ok "${address}" || die "mail-relay: '${address}' is not an e-mail address." + done + hostname=$(opt hostname) + [[ -z ${hostname} ]] || mail_valid_domain "${hostname}" || + die "LSS_MAIL_RELAY_HOSTNAME: '${hostname}' is not a full host name like server.example.com." + return +} + +mod_install() { + mail_postfix_install + return +} + +# _relay_nexthop: the relayhost in Postfix's form, [HOST]:PORT (brackets: no MX lookup for the smarthost). +_relay_nexthop() { + local relayhost + relayhost=$(opt relayhost) + [[ -n ${relayhost} ]] || return 0 + if [[ ${relayhost} == *:* ]]; then + printf '[%s]:%s' "${relayhost%:*}" "${relayhost##*:}" + else + printf '[%s]:25' "${relayhost}" + fi + return +} + +# _relay_tls_level: secure (encrypted, certificate checked) or encrypt for a smarthost; for direct delivery DANE with a +# validating resolver (dns-resolver), opportunistic TLS otherwise, as the receiving servers decide. +_relay_tls_level() { + if [[ -n $(opt relayhost) ]]; then + if opt_bool tls_verify; then printf 'secure'; else printf 'encrypt'; fi + elif state_installed dns-resolver; then + printf 'dane' + else + printf 'may' + fi + return +} + +# _relay_main HOSTNAME: main.cf of the relay. +_relay_main() { + local hostname=$1 nexthop level + nexthop=$(_relay_nexthop) + level=$(_relay_tls_level) + cat <=TLSv1.2 +smtp_tls_protocols = >=TLSv1.2 +smtp_tls_CAfile = $(mail_ca_bundle) +smtp_tls_loglevel = 1 +EOF + if [[ ${level} == dane ]]; then + printf 'smtp_dns_support_level = dnssec\n' + fi + if [[ ${nexthop} == *:465 ]]; then + printf 'smtp_tls_wrappermode = yes\n' + fi + if [[ -n $(opt user) ]]; then + printf 'smtp_sasl_auth_enable = yes\nsmtp_sasl_password_maps = texthash:%s\n' "${RELAY_SASL}" + printf 'smtp_sasl_security_options = noanonymous\nsmtp_sasl_tls_security_options = noanonymous\n' + fi + if [[ -n $(opt from) ]]; then + printf 'smtp_generic_maps = texthash:%s\n' "${RELAY_GENERIC}" + fi + return +} + +_relay_master() { + printf '# Managed by lss (module mail-relay).\n' + printf 'smtp inet n - n - - smtpd\n' + mail_master_cf + return +} + +# _relay_tables HOSTNAME: the aliases, the login at the smarthost and the sender rewriting. +_relay_tables() { + local hostname=$1 forward from password + forward=$(opt forward) + { + printf '# Managed by lss (module mail-relay).\npostmaster: root\nabuse: root\n' + [[ -z ${forward} ]] || printf 'root: %s\n' "${forward}" + } | file_write 0644 root:root "${RELAY_ALIASES}" + if [[ -n $(opt user) ]]; then + password=$(opt password) + if [[ -n ${password} ]]; then + log_mask "${password}" + if ! secret_exists mail-relay-password || [[ $(secret_get mail-relay-password) != "${password}" ]]; then + secret_set mail-relay-password "${password}" + fi + fi + secret_exists mail-relay-password || die "mail-relay: set LSS_MAIL_RELAY_PASSWORD for the login at the smarthost." + secret_load mail-relay-password password + # Read by Postfix as root before its daemons drop their privileges. + printf '%s %s:%s\n' "$(_relay_nexthop)" "$(opt user)" "${password}" | file_write 0600 root:root "${RELAY_SASL}" + else + file_remove "${RELAY_SASL}" + fi + from=$(opt from) + if [[ -n ${from} ]]; then + printf '@%s %s\n@localhost %s\n' "${hostname}" "${from}" "${from}" | file_write 0644 root:root "${RELAY_GENERIC}" + else + file_remove "${RELAY_GENERIC}" + fi + return +} + +mod_configure() { + local before hostname main master + before=$(files_changes_of mail-relay) + hostname=$(mail_fqdn "$(opt hostname)") + _relay_tables "${hostname}" + main=$(_relay_main "${hostname}") + master=$(_relay_master) + mail_postfix_sandbox ReadWritePaths=-/var/mail ReadWritePaths=-/var/spool/mail + mail_postfix_apply "${main}" "${master}" "${before}" + state_set mail-relay hostname "${hostname}" + return +} + +mod_verify() { + local hostname + hostname=$(mail_fqdn "$(opt hostname)") + svc_is_active "$(mail_postfix_unit)" || die "mail-relay: Postfix is not running." + postfix check >/dev/null 2>&1 || die "mail-relay: postfix check reports a problem." + [[ $(postconf -h inet_interfaces) == loopback-only ]] || die "mail-relay: Postfix must listen on the loopback addresses only." + [[ $(postconf -h relayhost) == "$(_relay_nexthop)" ]] || die "mail-relay: Postfix does not use the relayhost of the settings." + if [[ -e ${RELAY_SASL} ]]; then + [[ $(stat -c '%a %U' "${RELAY_SASL}") == '600 root' ]] || die "mail-relay: ${RELAY_SASL} must be mode 600, owned by root." + fi + mon_listen_check 25 127.0.0.1 ::1 + command -v sendmail >/dev/null 2>&1 || die "mail-relay: the sendmail command is missing." + mail_send_check "root@${hostname}" "${MAIL_VERIFY_LOCALPART}@${hostname}" >/dev/null + return +} + +mod_remove() { + local unit=postfix.service + os_is_debian && unit=postfix@.service + svc_disable "$(mail_postfix_unit)" + svc_disable postfix + svc_dropin_remove "${unit}" lss-sandbox + file_remove "${RELAY_ALIASES}" + file_remove "${RELAY_SASL}" + file_remove "${RELAY_GENERIC}" + files_forget_path "${MAIL_POSTFIX_MAIN}" + files_forget_path "${MAIL_POSTFIX_MASTER}" + pkg_remove postfix + if [[ ${LSS_PURGE:-0} == 1 ]]; then + secret_remove mail-relay-password + fi + return +} diff --git a/modules/mail/mailserver.sh b/modules/mail/mailserver.sh new file mode 100644 index 0000000..ac2d2a1 --- /dev/null +++ b/modules/mail/mailserver.sh @@ -0,0 +1,850 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="mailserver" +MOD_GROUP="mail" +MOD_DESCRIPTION="Mail server for your domains: Postfix (SMTP, submission with TLS and login only), Dovecot (IMAPS), Rspamd (spam filter, DKIM signing, SPF, DKIM and DMARC checks); prints the DNS records to set" +MOD_REQUIRES="system" +MOD_CONFLICTS="mail-relay" +MOD_OPTIONS="domains||Mail domains, separated by spaces; the first one is the main domain (required) +hostname||Host name of the mail server (MX record, greeting, certificate); empty: mail.
+users||Mailboxes, separated by spaces: addresses (alice@example.com) or local parts of the main domain (alice); the password of each with lss secrets show mailbox-alice_example.com +aliases||Aliases as SOURCE:TARGET, separated by spaces (info:alice, sales@example.org:bob@example.com, team:alice,bob); postmaster and abuse of every domain go to the first mailbox unless set here +certificate||Certificate directory below /etc/lss/tls, for example default from the tls module when LSS_TLS_DOMAINS contains the host name; empty: a self-signed certificate +dkim_selector|lss|DKIM selector (the DNS name SELECTOR._domainkey.DOMAIN) +dmarc_policy|quarantine|Policy in the printed DMARC record: none, quarantine or reject +message_size|25|Largest message in MB +spam_reject|15|Rspamd score from which a message is rejected +spam_header|6|Rspamd score from which a message gets a spam header (X-Spam: Yes)" +MOD_SECRETS="mailbox-_" +MOD_PORTS="25/tcp (SMTP), 465/tcp and 587/tcp (submission, TLS and login required), 993/tcp (IMAPS); IMAP 143 and submission 10587 for webmail on 127.0.0.1 only" + +MS_DOVECOT_CONF=/etc/dovecot/dovecot.conf +MS_USERS=/etc/dovecot/lss-users +MS_VMAIL=/var/vmail +MS_VMAIL_USER=vmail +MS_MAILBOXES=/etc/postfix/lss-mailboxes +MS_VIRTUAL=/etc/postfix/lss-virtual +MS_SENDERS=/etc/postfix/lss-senders +MS_ALIASES=/etc/postfix/lss-aliases +MS_RECIPIENT_ACCESS=/etc/postfix/lss-recipient-access +MS_RSPAMD=/etc/rspamd/local.d +MS_DKIM_DIR=/var/lib/rspamd/dkim +MS_RSPAMD_USER=_rspamd +MS_DNS_FILE=/etc/lss/mailserver-dns.txt +MS_MILTER=127.0.0.1:11332 +MS_SCANNER=127.0.0.1:11333 +# Webmail on the same server logs in on these listeners, which only 127.0.0.1 reaches. +MS_LOCAL_IMAP=143 +MS_LOCAL_SUBMISSION=10587 +# Mailboxes, aliases and domains as parsed by _ms_parse. +declare -ga MS_DOMAINS=() MS_ADDRESSES=() +declare -gA MS_ALIAS=() + +# _ms_parse: reads the domains, mailboxes and aliases of the settings into MS_DOMAINS, MS_ADDRESSES and MS_ALIAS (source +# address -> comma-separated targets), with the postmaster and abuse defaults. Stops on anything invalid. +_ms_parse() { + local domain entry address source targets target out local_part key + local -A seen=() + read -ra MS_DOMAINS <<<"$(opt domains)" + ((${#MS_DOMAINS[@]} > 0)) || die "LSS_MAILSERVER_DOMAINS is empty: name at least one mail domain (example.com)." + for domain in "${MS_DOMAINS[@]}"; do + mail_valid_domain "${domain}" || die "LSS_MAILSERVER_DOMAINS: '${domain}' is not a domain like example.com." + [[ -z ${seen[${domain}]+x} ]] || die "LSS_MAILSERVER_DOMAINS names ${domain} twice." + seen[${domain}]=1 + done + MS_ADDRESSES=() + for entry in $(opt users); do + address=$(_ms_address "${entry}") + _ms_check_address "${address}" LSS_MAILSERVER_USERS + [[ ${address%@*} != "${MAIL_VERIFY_LOCALPART}" ]] || die "LSS_MAILSERVER_USERS: ${MAIL_VERIFY_LOCALPART} is reserved for lss verify." + [[ -z ${seen[${address}]+x} ]] || die "LSS_MAILSERVER_USERS names ${address} twice." + seen[${address}]=1 + MS_ADDRESSES+=("${address}") + done + MS_ALIAS=() + for entry in $(opt aliases); do + [[ ${entry} == *:* ]] || die "LSS_MAILSERVER_ALIASES: '${entry}' is not SOURCE:TARGET." + source=$(_ms_address "${entry%%:*}") + _ms_check_address "${source}" LSS_MAILSERVER_ALIASES + [[ -z ${seen[${source}]+x} ]] || die "LSS_MAILSERVER_ALIASES: ${source} is a mailbox or another alias already." + seen[${source}]=1 + out='' + IFS=, read -ra targets <<<"${entry#*:}" + ((${#targets[@]} > 0)) || die "LSS_MAILSERVER_ALIASES: '${entry}' has no target." + for target in "${targets[@]}"; do + target=$(_ms_address "${target}") + [[ ${target} =~ ^[a-z0-9._+-]+@([a-z0-9-]+\.)+[a-z0-9-]+$ ]] || + die "LSS_MAILSERVER_ALIASES: '${target}' is not an e-mail address." + out+="${out:+,}${target}" + done + MS_ALIAS[${source}]=${out} + done + # Every domain needs postmaster (RFC 5321) and abuse; they go to the first mailbox unless the settings say otherwise. + if ((${#MS_ADDRESSES[@]} > 0)); then + for domain in "${MS_DOMAINS[@]}"; do + for local_part in postmaster abuse; do + key="${local_part}@${domain}" + [[ -n ${seen[${key}]+x} ]] || MS_ALIAS[${key}]=${MS_ADDRESSES[0]} + done + done + fi + return +} + +# _ms_address ENTRY: ENTRY as an address; a local part alone belongs to the main domain. +_ms_address() { + local entry=${1,,} + if [[ ${entry} == *@* ]]; then + printf '%s' "${entry}" + else + printf '%s@%s' "${entry}" "${MS_DOMAINS[0]}" + fi + return +} + +# _ms_check_address ADDRESS SETTING: a mailbox or alias address must be a local part at one of the mail domains. +_ms_check_address() { + local address=$1 setting=$2 domain + [[ ${address%@*} =~ ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$ ]] || + die "${setting}: '${address}' needs a local part of a-z, 0-9, '.', '_' and '-'." + for domain in "${MS_DOMAINS[@]}"; do + [[ ${address#*@} != "${domain}" ]] || return 0 + done + die "${setting}: ${address} is not at one of the mail domains (${MS_DOMAINS[*]})." +} + +# _ms_secret ADDRESS: the name of the secret with the password of a mailbox. +_ms_secret() { + printf 'mailbox-%s' "${1/@/_}" + return +} + +_ms_hostname() { + local hostname + hostname=$(opt hostname) + printf '%s' "${hostname:-mail.${MS_DOMAINS[0]}}" + return +} + +# _ms_cert_dir: where the certificate of the mail host name is. +_ms_cert_dir() { + local name + name=$(opt certificate) + if [[ -n ${name} ]]; then + printf '%s/%s' "${WEB_TLS_DIR}" "${name}" + else + printf '%s' "${MAIL_TLS_DIR}" + fi + return +} + +mod_check() { + local hostname number name domain + _ms_parse + hostname=$(_ms_hostname) + mail_valid_domain "${hostname}" || die "LSS_MAILSERVER_HOSTNAME: '${hostname}' is not a full host name like mail.example.com." + for domain in "${MS_DOMAINS[@]}"; do + [[ ${domain} != "${hostname}" ]] || + die "LSS_MAILSERVER_HOSTNAME must differ from the mail domains (use mail.${domain} as the host name)." + done + [[ $(opt dkim_selector) =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || + die "LSS_MAILSERVER_DKIM_SELECTOR must be a DNS label such as lss or 2026." + [[ $(opt dmarc_policy) =~ ^(none|quarantine|reject)$ ]] || die "LSS_MAILSERVER_DMARC_POLICY must be none, quarantine or reject." + number=$(opt_int message_size) + ((number >= 1 && number <= 200)) || die "LSS_MAILSERVER_MESSAGE_SIZE must be between 1 and 200 (MB)." + (($(opt_int spam_header) < $(opt_int spam_reject))) || + die "LSS_MAILSERVER_SPAM_HEADER must be lower than LSS_MAILSERVER_SPAM_REJECT." + name=$(opt certificate) + if [[ -n ${name} ]]; then + [[ ${name} =~ ^[a-z0-9][a-z0-9.-]*$ ]] || die "LSS_MAILSERVER_CERTIFICATE: '${name}' is not a certificate name." + fi + return +} + +mod_install() { + mail_postfix_install + if os_is_rhel; then + pkg_enable_epel + fi + repo_add rspamd + pkg_install_family "dovecot-core dovecot-imapd dovecot-lmtpd rspamd openssl" "dovecot rspamd openssl" + return +} + +# --- Dovecot --------------------------------------------------------------------------------------------------------- + +# _ms_dovecot_24: Dovecot 2.4 (Debian 13, Ubuntu 26.04) has a new configuration syntax; the others run 2.3. +_ms_dovecot_24() { + [[ $(dovecot --version 2>/dev/null) == 2.4* ]] + return +} + +_ms_vmail_user() { + if ! getent passwd "${MS_VMAIL_USER}" >/dev/null; then + useradd --system --user-group --home-dir "${MS_VMAIL}" --no-create-home --shell "$(command -v nologin)" \ + "${MS_VMAIL_USER}" + files_note_change "created user ${MS_VMAIL_USER}" + fi + if [[ ! -d ${MS_VMAIL} ]]; then + install -d -m 0700 -o "${MS_VMAIL_USER}" -g "${MS_VMAIL_USER}" "${MS_VMAIL}" + files_note_change "created ${MS_VMAIL}" + fi + # Under SELinux Dovecot may write mailboxes only with the label of the mail spool. + if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled && command -v semanage >/dev/null 2>&1; then + if ! semanage fcontext -l -C 2>/dev/null | grep -qF "${MS_VMAIL}(/.*)?"; then + semanage fcontext -a -t mail_spool_t "${MS_VMAIL}(/.*)?" + files_note_change "SELinux label for ${MS_VMAIL}" + fi + restorecon -R "${MS_VMAIL}" 2>/dev/null || true + fi + return +} + +# _ms_hash SECRET [OLD]: the SHA512-CRYPT hash of a password, the one in OLD when it still matches (a new salt on every +# run would rewrite the user file each time). The password goes to openssl on standard input. +_ms_hash() { + local name=$1 old=${2:-} password salt hash + secret_load "${name}" password 24 + if [[ ${old} =~ ^\$6\$([A-Za-z0-9./]{1,16})\$ ]]; then + salt=${BASH_REMATCH[1]} + hash=$(openssl passwd -6 -salt "${salt}" -stdin <<<"${password}") + if [[ ${hash} == "${old}" ]]; then + printf '%s' "${old}" + return 0 + fi + fi + openssl passwd -6 -stdin <<<"${password}" + return +} + +# _ms_users_file: the passwd-file of Dovecot: every mailbox with its hash, uid, gid and home, and the verify mailbox, +# which cannot log in (a random password nobody knows, and nologin). +_ms_users_file() { + local address old hash uid gid home + local -A current=() + uid=$(id -u "${MS_VMAIL_USER}") + gid=$(id -g "${MS_VMAIL_USER}") + if [[ -f ${MS_USERS} ]]; then + while IFS=: read -r address hash _; do + [[ ${address} == '#'* || -z ${address} ]] || current[${address}]=${hash#\{SHA512-CRYPT\}} + done <"${MS_USERS}" + fi + printf '# Managed by lss (module mailserver). Mailboxes come from LSS_MAILSERVER_USERS.\n' + for address in "${MS_ADDRESSES[@]}" "${MAIL_VERIFY_LOCALPART}@${MS_DOMAINS[0]}"; do + old=${current[${address}]:-} + if [[ ${address%@*} == "${MAIL_VERIFY_LOCALPART}" ]]; then + hash=$(_ms_hash mailserver-verify "${old}") + else + hash=$(_ms_hash "$(_ms_secret "${address}")" "${old}") + fi + home="${MS_VMAIL}/${address#*@}/${address%@*}" + printf '%s:{SHA512-CRYPT}%s:%s:%s::%s::' "${address}" "${hash}" "${uid}" "${gid}" "${home}" + [[ ${address%@*} != "${MAIL_VERIFY_LOCALPART}" ]] || printf 'nologin=y reason=lss-verify' + printf '\n' + done + return +} + +# _ms_dovecot_conf CERTDIR: the whole dovecot.conf (the distribution's conf.d is not included). +_ms_dovecot_conf() { + local cert=$1 listen='*' + [[ $(mail_inet_protocols) == ipv4 ]] || listen='*, ::' + printf '# Managed by lss (module mailserver). Change LSS_MAILSERVER_* in /etc/lss/lss.conf and run lss configure mailserver.\n' + if _ms_dovecot_24; then + cat <&1 >/dev/null) || { + printf '%s\n' "${output}" >&2 + die "mailserver: Dovecot rejects ${MS_DOVECOT_CONF}." + } + svc_sandbox dovecot.service "ReadWritePaths=${MS_VMAIL}" ReadWritePaths=-/var/lib/dovecot \ + ReadWritePaths=/var/spool/postfix/private + svc_enable dovecot + if [[ $(files_changes_of mailserver) != "${before}" ]]; then + svc_restart dovecot || die "mailserver: Dovecot does not start with the new configuration." + fi + return +} + +# --- Rspamd ---------------------------------------------------------------------------------------------------------- + +# _ms_dkim_key DOMAIN: the DKIM key of DOMAIN (RSA 2048, made once); prints the public key for the DNS record. +_ms_dkim_key() { + local domain=$1 key + key="${MS_DKIM_DIR}/${domain}.$(opt dkim_selector).key" + install -d -m 0750 -o root -g "${MS_RSPAMD_USER}" "${MS_DKIM_DIR}" + if [[ ! -s ${key} ]]; then + ( + umask 077 + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${key}.new" 2>/dev/null + ) || die "mailserver: could not create the DKIM key for ${domain}." + mv -f -- "${key}.new" "${key}" + files_note_change "DKIM key for ${domain}" + fi + if [[ $(stat -c '%a %U:%G' "${key}") != "640 root:${MS_RSPAMD_USER}" ]]; then + chown "root:${MS_RSPAMD_USER}" "${key}" + chmod 0640 "${key}" + fi + openssl pkey -in "${key}" -pubout -outform DER 2>/dev/null | base64 -w 0 + return +} + +_ms_rspamd() { + local before + before=$(files_changes_of mailserver) + install -d -m 0755 "${MS_RSPAMD}" + # The scanner and the milter on 127.0.0.1 only; they never read files a client names. The controller (web interface, + # training) stays off. + file_write 0644 root:root "${MS_RSPAMD}/worker-normal.inc" </dev/null 2>&1 || { + rspamadm configtest >&2 || true + die "mailserver: Rspamd rejects its configuration." + } + svc_sandbox rspamd.service ReadWritePaths=/var/lib/rspamd ReadWritePaths=-/var/log/rspamd + svc_enable rspamd + if [[ $(files_changes_of mailserver) != "${before}" ]]; then + svc_restart rspamd || die "mailserver: Rspamd does not start with the new configuration." + fi + return +} + +# --- Postfix --------------------------------------------------------------------------------------------------------- + +# _ms_tables: the mailboxes, the aliases, which login may send as which address, and the local aliases of root. +_ms_tables() { + local address source target verify + local -a owners=() + verify="${MAIL_VERIFY_LOCALPART}@${MS_DOMAINS[0]}" + { + printf '# Managed by lss (module mailserver).\n' + for address in "${MS_ADDRESSES[@]}" "${verify}"; do + printf '%s OK\n' "${address}" + done + } | file_write 0644 root:root "${MS_MAILBOXES}" + { + printf '# Managed by lss (module mailserver).\n' + for source in $(printf '%s\n' "${!MS_ALIAS[@]}" | sort); do + printf '%s %s\n' "${source}" "${MS_ALIAS[${source}]}" + done + } | file_write 0644 root:root "${MS_VIRTUAL}" + # A login sends as its own address and as the aliases that lead to it. + { + printf '# Managed by lss (module mailserver).\n' + for address in "${MS_ADDRESSES[@]}"; do + printf '%s %s\n' "${address}" "${address}" + done + for source in $(printf '%s\n' "${!MS_ALIAS[@]}" | sort); do + owners=() + for target in ${MS_ALIAS[${source}]//,/ }; do + [[ " ${MS_ADDRESSES[*]} " != *" ${target} "* ]] || owners+=("${target}") + done + ((${#owners[@]} == 0)) || printf '%s %s\n' "${source}" "$( + IFS=, + printf '%s' "${owners[*]}" + )" + done + } | file_write 0644 root:root "${MS_SENDERS}" + # Nobody outside reaches the verify mailbox; lss verify delivers to it with sendmail. + printf '# Managed by lss (module mailserver).\n%s REJECT\n' "${verify}" | + file_write 0644 root:root "${MS_RECIPIENT_ACCESS}" + { + printf '# Managed by lss (module mailserver). Mail for root and the system accounts.\n' + printf 'postmaster: root\nabuse: root\nroot: postmaster@%s\n' "${MS_DOMAINS[0]}" + } | file_write 0644 root:root "${MS_ALIASES}" + return +} + +_ms_main() { + local hostname=$1 cert=$2 level=may + state_installed dns-resolver && level=dane + cat <=TLSv1.2 +smtpd_tls_mandatory_protocols = >=TLSv1.2 +smtpd_tls_mandatory_ciphers = high +smtpd_tls_received_header = yes +smtpd_tls_loglevel = 1 +smtp_tls_security_level = ${level} +smtp_tls_protocols = >=TLSv1.2 +smtp_tls_mandatory_protocols = >=TLSv1.2 +smtp_tls_CAfile = $(mail_ca_bundle) +smtp_tls_loglevel = 1 +# Every message passes Rspamd: spam filter, DKIM signing, SPF, DKIM and DMARC checks. While Rspamd is down, mail waits. +smtpd_milters = inet:${MS_MILTER} +non_smtpd_milters = \$smtpd_milters +milter_default_action = tempfail +milter_protocol = 6 +milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen} +EOF + [[ ${level} != dane ]] || printf 'smtp_dns_support_level = dnssec\n' + return +} + +# _ms_submission NAME LISTEN [OPTION...]: a master.cf listener for logged-in users (submission). +_ms_submission() { + local name=$1 listen=$2 option + shift 2 + printf '%-9s inet n - n - - smtpd\n' "${listen}" + for option in "syslog_name=postfix/${name}" smtpd_sasl_auth_enable=yes \ + smtpd_client_restrictions=permit_sasl_authenticated,reject smtpd_helo_restrictions= \ + smtpd_sender_restrictions=reject_sender_login_mismatch smtpd_recipient_restrictions= \ + smtpd_relay_restrictions=permit_sasl_authenticated,reject milter_macro_daemon_name=ORIGINATING "$@"; do + printf ' -o %s\n' "${option}" + done + return +} + +_ms_master() { + printf '# Managed by lss (module mailserver).\n' + printf 'smtp inet n - n - - smtpd\n' + _ms_submission submission submission smtpd_tls_security_level=encrypt smtpd_tls_auth_only=yes + _ms_submission submissions 465 smtpd_tls_wrappermode=yes smtpd_tls_security_level=encrypt smtpd_tls_auth_only=yes + _ms_submission webmail "127.0.0.1:${MS_LOCAL_SUBMISSION}" smtpd_tls_security_level=none smtpd_tls_auth_only=no \ + smtpd_sasl_security_options=noanonymous + mail_master_cf + return +} + +# --- DNS records ----------------------------------------------------------------------------------------------------- + +# _ms_ptr4 ADDRESS / _ms_ptr6 ADDRESS: the reverse DNS name of an IPv4 or IPv6 address. +_ms_ptr4() { + local a b c d + IFS=. read -r a b c d <<<"$1" + printf '%s.%s.%s.%s.in-addr.arpa.' "${d}" "${c}" "${b}" "${a}" + return +} + +_ms_ptr6() { + local address=$1 group full='' out='' i missing + local -a head=() tail=() groups=() + if [[ ${address} == *::* ]]; then + IFS=: read -ra head <<<"${address%%::*}" + IFS=: read -ra tail <<<"${address#*::}" + missing=$((8 - ${#head[@]} - ${#tail[@]})) + groups=("${head[@]}") + for ((i = 0; i < missing; i++)); do + groups+=(0) + done + groups+=("${tail[@]}") + else + IFS=: read -ra groups <<<"${address}" + fi + for group in "${groups[@]}"; do + full+=$(printf '%04x' "$((16#${group:-0}))") + done + for ((i = ${#full} - 1; i >= 0; i--)); do + out+="${full:i:1}." + done + printf '%sip6.arpa.' "${out}" + return +} + +# _ms_txt TEXT: TEXT as the quoted strings of a TXT record (at most 255 characters each). +_ms_txt() { + local text=$1 out='' + while ((${#text} > 255)); do + out+="\"${text:0:255}\" " + text=${text:255} + done + printf '%s"%s"' "${out}" "${text}" + return +} + +# _ms_dns_records HOSTNAME IPV4 IPV6 DOMAIN=PUBLICKEY...: the records lss cannot set, as zone file lines. +_ms_dns_records() { + local hostname=$1 ipv4=$2 ipv6=$3 entry domain key + shift 3 + [[ -z ${ipv4} ]] || printf '%s. A %s\n' "${hostname}" "${ipv4}" + [[ -z ${ipv6} ]] || printf '%s. AAAA %s\n' "${hostname}" "${ipv6}" + for entry in "$@"; do + domain=${entry%%=*} + key=${entry#*=} + printf '%s. MX 10 %s.\n' "${domain}" "${hostname}" + printf '%s. TXT %s\n' "${domain}" "$(_ms_txt 'v=spf1 mx -all')" + printf '%s._domainkey.%s. TXT %s\n' "$(opt dkim_selector)" "${domain}" "$(_ms_txt "v=DKIM1; k=rsa; p=${key}")" + printf '_dmarc.%s. TXT %s\n' "${domain}" \ + "$(_ms_txt "v=DMARC1; p=$(opt dmarc_policy); adkim=r; aspf=r; rua=mailto:postmaster@${domain}")" + done + # Reverse DNS belongs to whoever owns the address (the hosting provider); it must name the mail host. + [[ -z ${ipv4} ]] || printf '%s PTR %s.\n' "$(_ms_ptr4 "${ipv4}")" "${hostname}" + [[ -z ${ipv6} ]] || printf '%s PTR %s.\n' "$(_ms_ptr6 "${ipv6}")" "${hostname}" + return +} + +# _ms_ipv6: the IPv6 address this server sends from on its default route, if it has one. +_ms_ipv6() { + local route + route=$(ip -6 -o route get 2001:db8::1 2>/dev/null) || return 0 + sed -n 's/.* src \([0-9a-fA-F:]*\).*/\1/p' <<<"${route}" + return +} + +_ms_dns() { + local hostname=$1 domain key records line + local -a keys=() + for domain in "${MS_DOMAINS[@]}"; do + key=$(_ms_dkim_key "${domain}") + keys+=("${domain}=${key}") + done + records=$(_ms_dns_records "${hostname}" "$(_fw_host_addresses)" "$(_ms_ipv6)" "${keys[@]}") + { + printf '; Managed by lss (module mailserver). DNS records the mail server needs; lss cannot set them.\n' + printf '%s\n' "${records}" + } | file_write 0644 root:root "${MS_DNS_FILE}" + if [[ ${LSS_FILE_CHANGED} == 1 ]]; then + log_info "mailserver: set these DNS records at your DNS provider (also in ${MS_DNS_FILE}); the PTR records at the provider of the addresses:" + while IFS= read -r line; do + log_info "mailserver: ${line}" + done <<<"${records}" + fi + return +} + +mod_configure() { + local before hostname cert main master + before=$(files_changes_of mailserver) + _ms_parse + hostname=$(_ms_hostname) + cert=$(_ms_cert_dir) + if [[ -z $(opt certificate) ]]; then + mail_tls_cert "${hostname}" + else + [[ -s ${cert}/fullchain.pem && -s ${cert}/privkey.pem ]] || + die "mailserver: ${cert} has no certificate (fullchain.pem, privkey.pem)." + fi + _ms_vmail_user + _ms_rspamd + _ms_dns "${hostname}" + _ms_dovecot "${cert}" + _ms_tables + main=$(_ms_main "${hostname}" "${cert}") + master=$(_ms_master) + mail_postfix_sandbox + mail_postfix_apply "${main}" "${master}" "${before}" + fw_allow 25/tcp 465/tcp 587/tcp 993/tcp + state_set mailserver hostname "${hostname}" + state_set mailserver domain "${MS_DOMAINS[0]}" + return +} + +# --- Verify ---------------------------------------------------------------------------------------------------------- + +# _ms_gtube: the GTUBE test message, which every spam filter rejects. +_ms_gtube() { + printf 'From: lss@%s\nTo: %s@%s\nSubject: lss spam filter test\nMessage-ID: \n\n' \ + "${MS_DOMAINS[0]}" "${MAIL_VERIFY_LOCALPART}" "${MS_DOMAINS[0]}" "${MS_DOMAINS[0]}" + printf 'XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\n' + return +} + +# _ms_served_cert PORT [STARTTLS]: the SHA-256 fingerprint of the certificate a TLS port on 127.0.0.1 presents. +_ms_served_cert() { + local port=$1 starttls=${2:-} + local -a args=(-connect "127.0.0.1:${port}") + [[ -z ${starttls} ]] || args+=(-starttls "${starttls}") + openssl s_client "${args[@]}" /dev/null | openssl x509 -noout -fingerprint -sha256 2>/dev/null || true + return +} + +mod_verify() { + local hostname cert expected served port verify id message try result key unit domain + _ms_parse + hostname=$(_ms_hostname) + cert=$(_ms_cert_dir) + verify="${MAIL_VERIFY_LOCALPART}@${MS_DOMAINS[0]}" + for unit in "$(mail_postfix_unit)" dovecot rspamd; do + svc_is_active "${unit}" || die "mailserver: ${unit} is not running." + done + postfix check >/dev/null 2>&1 || die "mailserver: postfix check reports a problem." + doveconf -n >/dev/null 2>&1 || die "mailserver: Dovecot reports a problem with its configuration." + [[ $(stat -c '%a %U:%G' "${MS_USERS}") == '640 root:dovecot' ]] || die "mailserver: ${MS_USERS} must be mode 640, root:dovecot." + for port in 25 465 587 993; do + [[ -n $(ss -Hltn "sport = :${port}") ]] || die "mailserver: nothing listens on port ${port}." + done + mon_listen_check "${MS_LOCAL_IMAP}" 127.0.0.1 + mon_listen_check "${MS_LOCAL_SUBMISSION}" 127.0.0.1 + mon_listen_check "${MS_MILTER##*:}" 127.0.0.1 + mon_listen_check "${MS_SCANNER##*:}" 127.0.0.1 + # IMAPS and submission present the certificate of the mail host name. + expected=$(openssl x509 -noout -fingerprint -sha256 -in "${cert}/fullchain.pem") + for port in 993:'' 465:'' 587:smtp; do + served='' + for ((try = 0; try < 10; try++)); do + served=$(_ms_served_cert "${port%%:*}" "${port#*:}") + [[ ${served} != "${expected}" ]] || break + sleep 1 + done + [[ ${served} == "${expected}" ]] || die "mailserver: port ${port%%:*} does not present the certificate in ${cert}." + done + # The spam filter rejects the GTUBE test message. + result='' + for ((try = 0; try < 30; try++)); do + result=$(_ms_gtube | rspamc -h "${MS_SCANNER}" symbols 2>/dev/null) || true + [[ ${result} != *'Action: reject'* ]] || break + sleep 2 + done + [[ ${result} == *'Action: reject'* && ${result} == *GTUBE* ]] || die "mailserver: Rspamd does not reject the GTUBE test message." + # The DNS records name the DKIM key Rspamd signs with. + for domain in "${MS_DOMAINS[@]}"; do + key="${MS_DKIM_DIR}/${domain}.$(opt dkim_selector).key" + [[ -s ${key} ]] || die "mailserver: the DKIM key ${key} is missing." + grep -qF "p=$(openssl pkey -in "${key}" -pubout -outform DER 2>/dev/null | base64 -w 0)" "${MS_DNS_FILE}" || + die "mailserver: ${MS_DNS_FILE} does not carry the DKIM key of ${domain}." + done + # A message through the whole server: sendmail, Postfix, Rspamd (signs it), LMTP into the verify mailbox. + id=$(mail_send_check "postmaster@${MS_DOMAINS[0]}" "${verify}") + message='' + for ((try = 0; try < 20; try++)); do + message=$(doveadm fetch -u "${verify}" hdr mailbox INBOX header Message-ID "${id}" 2>/dev/null) || true + [[ -z ${message} ]] || break + sleep 1 + done + doveadm expunge -u "${verify}" mailbox INBOX all >/dev/null 2>&1 || true + [[ -n ${message} ]] || die "mailserver: a test message did not arrive in the mailbox ${verify}." + # Unfolded, so a header that continues on the next line is one line. + message=${message//$'\n\t'/ } + message=${message//$'\n '/ } + grep -qiE "^DKIM-Signature:.*[; ]d=${MS_DOMAINS[0]//./\\.};" <<<"${message}" || + die "mailserver: Rspamd did not sign the test message with the DKIM key of ${MS_DOMAINS[0]}." + grep -qi '^X-Spam-Status:' <<<"${message}" || die "mailserver: the test message did not pass the spam filter." + return +} + +mod_remove() { + local unit=postfix.service + os_is_debian && unit=postfix@.service + svc_disable rspamd + svc_disable dovecot + svc_disable "$(mail_postfix_unit)" + svc_disable postfix + svc_dropin_remove "${unit}" lss-sandbox + svc_dropin_remove dovecot.service lss-sandbox + svc_dropin_remove rspamd.service lss-sandbox + local file + for file in "${MS_MAILBOXES}" "${MS_VIRTUAL}" "${MS_SENDERS}" "${MS_ALIASES}" "${MS_RECIPIENT_ACCESS}" "${MS_USERS}" \ + "${MS_DOVECOT_CONF}" "${MS_DNS_FILE}" "${MS_RSPAMD}/worker-normal.inc" "${MS_RSPAMD}/worker-proxy.inc" \ + "${MS_RSPAMD}/worker-controller.inc" "${MS_RSPAMD}/actions.conf" "${MS_RSPAMD}/dkim_signing.conf" \ + "${MS_RSPAMD}/milter_headers.conf" "${MS_RSPAMD}/logging.inc"; do + file_remove "${file}" + done + files_forget_path "${MAIL_POSTFIX_MAIN}" + files_forget_path "${MAIL_POSTFIX_MASTER}" + pkg_remove_family "rspamd dovecot-imapd dovecot-lmtpd dovecot-core postfix" "rspamd dovecot postfix" + repo_remove rspamd + # Mailboxes, DKIM keys and passwords stay unless --purge. + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + rm -rf -- "${MS_VMAIL}" "${MS_DKIM_DIR}" "${MAIL_TLS_DIR}" + for file in "${LSS_SECRETS_DIR}"/mailbox-* "${LSS_SECRETS_DIR}/mailserver-verify"; do + if [[ -e ${file} ]]; then + secret_remove "${file##*/}" + fi + done + files_note_change "deleted the mailboxes and DKIM keys" + return +} diff --git a/modules/mail/webmail.sh b/modules/mail/webmail.sh new file mode 100644 index 0000000..79138a1 --- /dev/null +++ b/modules/mail/webmail.sh @@ -0,0 +1,249 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="webmail" +MOD_GROUP="mail" +MOD_DESCRIPTION="Roundcube 1.7.4 for the mailboxes of the mailserver module, as an HTTPS site of the web server (login with the mailbox address and password)" +MOD_REQUIRES="mailserver nginx|apache|caddy php" +MOD_OPTIONS="domains||Host names of the webmail site, separated by spaces; empty: the host name of the mail server (LSS_MAILSERVER_HOSTNAME) +product_name|Webmail|Name on the login page +session_lifetime|30|Minutes without activity before Roundcube asks for the password again" +MOD_SECRETS="webmail-des-key" +MOD_PORTS="none of its own (the HTTPS port of the web server)" + +# A new version is a new entry roundcube- in data/checksums.txt and a new RC_VERSION; the next install +# replaces the whole tree and updates the database schema. +RC_VERSION=1.7.4 +RC_DIR=/usr/share/lss/roundcube +RC_CONFIG=${RC_DIR}/config/config.inc.php +# Below /var/lib/php, which the sandbox of PHP-FPM may write and which carries the SELinux label PHP may write to. +RC_DATA=/var/lib/php/lss-roundcube +RC_DB=${RC_DATA}/roundcube.db +RC_SITE=webmail + +mod_check() { + local domain minutes + for domain in $(opt domains); do + [[ ${domain} =~ ^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$ ]] || + die "LSS_WEBMAIL_DOMAINS: '${domain}' is not a host name like mail.example.com." + done + [[ $(opt product_name) =~ ^[A-Za-z0-9\ ._-]{1,40}$ ]] || + die "LSS_WEBMAIL_PRODUCT_NAME may contain letters, digits, spaces, '.', '_' and '-' (up to 40)." + minutes=$(opt_int session_lifetime) + ((minutes >= 5 && minutes <= 1440)) || die "LSS_WEBMAIL_SESSION_LIFETIME must be between 5 and 1440 minutes." + fetch_entry "roundcube-${RC_VERSION}" >/dev/null || + die "webmail: data/checksums.txt has no checksum for Roundcube ${RC_VERSION}." + return +} + +# _rc_php: the PHP command line of the php module. +_rc_php() { + local version + version=$(state_get php php_version) + if os_is_debian; then printf '/usr/bin/php%s' "${version}"; else printf '/usr/bin/php'; fi + return +} + +# The release archive from GitHub, checked against data/checksums.txt, unpacked next to the old tree and swapped in. +# The installer is left out; lss writes the configuration itself. +mod_install() { + local tmp version + version=$(state_get php php_version) + # SQLite for the settings and contacts of the users (PDO SQLite comes with php-pdo on the RHEL family). + pkg_install_family "curl tar php${version}-sqlite3" "curl tar php-pdo" + [[ ! -f ${RC_DIR}/.lss-version-${RC_VERSION} ]] || return 0 + install -d -m 0755 "$(dirname "${RC_DIR}")" + tmp=$(mktemp -d "$(dirname "${RC_DIR}")/.roundcube.XXXXXX") + fetch_verified "roundcube-${RC_VERSION}" "${tmp}/roundcube.tar.gz" + install -d -m 0755 "${tmp}/tree" + tar -xzf "${tmp}/roundcube.tar.gz" -C "${tmp}/tree" --strip-components=1 --no-same-owner --no-same-permissions || + die "webmail: the archive of Roundcube ${RC_VERSION} does not unpack." + grep -qF "define('RCMAIL_VERSION', '${RC_VERSION}')" "${tmp}/tree/program/include/iniset.php" || + die "webmail: the archive does not contain Roundcube ${RC_VERSION}." + rm -rf -- "${tmp}/tree/installer" "${tmp}/tree/public_html/installer.php" "${tmp}/tree/temp" "${tmp}/tree/logs" + touch "${tmp}/tree/.lss-version-${RC_VERSION}" + chown -R root:root "${tmp}/tree" + chmod -R u=rwX,go=rX "${tmp}/tree" + # The configuration of the old tree is written again by mod_configure. + rm -rf -- "${RC_DIR}.old" + if [[ -d ${RC_DIR} ]]; then + mv -- "${RC_DIR}" "${RC_DIR}.old" + fi + mv -- "${tmp}/tree" "${RC_DIR}" + rm -rf -- "${RC_DIR}.old" "${tmp}" + if command -v restorecon >/dev/null 2>&1; then + restorecon -R "${RC_DIR}" 2>/dev/null || true + fi + files_note_change "installed Roundcube ${RC_VERSION}" + return +} + +# _rc_domains: the host names of the site. +_rc_domains() { + local domains + domains=$(opt domains) + [[ -n ${domains} ]] || domains=$(state_get mailserver hostname) + [[ -n ${domains} ]] || die "webmail: the mailserver module has no host name yet; run lss install mailserver." + printf '%s' "${domains}" + return +} + +# _rc_config GROUP: config.inc.php. Roundcube talks to Dovecot and Postfix on 127.0.0.1, where they accept a +# password without TLS because the connection does not leave the server. +_rc_config() { + local key domain + secret_load webmail-des-key key 32 + domain=$(state_get mailserver domain) + cat </dev/null 2>&1; then + restorecon -R "${RC_DATA}" 2>/dev/null || true + fi + if [[ ! -s ${RC_DB} ]]; then + log_command _rc_run "${RC_DIR}/bin/initdb.sh" --dir="${RC_DIR}/SQL" || die "webmail: Roundcube could not create its database." + files_note_change "created the Roundcube database" + elif [[ $(state_get webmail schema) != "${RC_VERSION}" ]]; then + log_command _rc_run "${RC_DIR}/bin/updatedb.sh" --package=roundcube --dir="${RC_DIR}/SQL" || + die "webmail: Roundcube could not update its database to ${RC_VERSION}." + files_note_change "updated the Roundcube database" + fi + state_set webmail schema "${RC_VERSION}" + return +} + +mod_configure() { + local user group config unit + local -a domains=() + user=$(web_php_pool_user) + user=${user:-lss-php} + group=$(web_php_pool_group) + config=$(_rc_config) + # Read by PHP-FPM only: the key encrypts the IMAP password in the session. + file_write 0640 "root:${group}" "${RC_CONFIG}" <<<"${config}" + _rc_database "${user}" "${group}" + read -ra domains <<<"$(_rc_domains)" + web_cert_placeholder "${RC_SITE}" + web_site_write "${RC_SITE}" php "${RC_DIR}/public_html" "${domains[@]}" + # PHP connects to IMAP and SMTP on 127.0.0.1; under SELinux that needs the network boolean of the web servers. + web_selinux_proxy + if [[ $(files_changes_of webmail) != 0 ]]; then + web_reload + # OPcache may not look at file times; a reload makes PHP-FPM read the new tree and load PDO SQLite. + while IFS= read -r unit; do + if [[ -n ${unit} ]] && svc_is_active "${unit}"; then + svc_reload "${unit}" || die "webmail: ${unit} does not reload." + fi + done < <(web_php_fpm_units) + fi + if state_installed tls; then + web_cert_issue "${RC_SITE}" "${domains[@]}" + fi + return +} + +# _rc_get DOMAIN PATH: a page of the site as this server serves it, followed by a line with the HTTP status. +_rc_get() { + local domain=$1 path=$2 + curl -ks --max-time 30 -w '\n%{http_code}' --resolve "${domain}:443:127.0.0.1" "https://${domain}/${path}" + return +} + +mod_verify() { + local domain page status asset try schema + local -a domains=() + [[ -f ${RC_DIR}/.lss-version-${RC_VERSION} ]] || die "webmail: Roundcube ${RC_VERSION} is missing in ${RC_DIR}." + [[ ! -e ${RC_DIR}/installer && ! -e ${RC_DIR}/public_html/installer.php ]] || + die "webmail: the Roundcube installer must not exist." + [[ $(stat -c '%a %U' "${RC_CONFIG}") == '640 root' ]] || die "webmail: ${RC_CONFIG} must be mode 640, owned by root." + schema=$(_rc_run -r "echo (new PDO('sqlite:${RC_DB}'))->query(\"SELECT value FROM system WHERE name = 'roundcube-version'\")->fetchColumn();" 2>/dev/null) || + true + [[ -n ${schema} ]] || die "webmail: the Roundcube database ${RC_DB} has no schema." + read -ra domains <<<"$(_rc_domains)" + domain=${domains[0]} + # Right after a reload the old workers of the web server answer for a moment without the site. + for ((try = 0; try < 15; try++)); do + page=$(_rc_get "${domain}" '') || true + status=${page##*$'\n'} + [[ ${status} != 200 ]] || break + sleep 1 + done + [[ ${status} == 200 ]] || die "webmail: https://${domain}/ answers ${status:-nothing} instead of the login page." + grep -qF 'name="_user"' <<<"${page}" || die "webmail: https://${domain}/ shows no login form." + if grep -qE '(Deprecated|Warning|Notice|Fatal error):|^(Deprecated|Warning|Fatal error): ' <<<"${page}"; then + die "webmail: the login page shows PHP errors; display_errors must be off." + fi + # Styles and scripts come through static.php/... (PATH_INFO), which the web server must pass to PHP. + asset=$(grep -oE 'static\.php/[^"?]+\.css' <<<"${page}" | head -n 1) || true + [[ -n ${asset} ]] || die "webmail: the login page links no style sheet." + page=$(_rc_get "${domain}" "${asset}") || true + [[ ${page##*$'\n'} == 200 ]] || die "webmail: https://${domain}/${asset} answers ${page##*$'\n'} instead of the style sheet." + return +} + +mod_remove() { + web_site_remove "${RC_SITE}" + web_reload + file_remove "${WEB_TLS_DIR}/${RC_SITE}" + if [[ -d /etc/letsencrypt/live/${RC_SITE} ]] && command -v certbot >/dev/null 2>&1; then + log_command certbot delete --non-interactive --cert-name "${RC_SITE}" || log_warn "webmail: certbot could not delete the certificate ${RC_SITE}." + fi + file_remove "${RC_CONFIG}" + if [[ -d ${RC_DIR} ]]; then + rm -rf -- "${RC_DIR}" + files_note_change "removed ${RC_DIR}" + fi + rmdir "$(dirname "${RC_DIR}")" 2>/dev/null || true + # The database holds the settings, contacts and identities of the users; it stays unless --purge. + if [[ ${LSS_PURGE:-0} == 1 ]]; then + rm -rf -- "${RC_DATA}" + secret_remove webmail-des-key + files_note_change "deleted the Roundcube database" + fi + return +} diff --git a/modules/security/guard.sh b/modules/security/guard.sh index c4bcb81..7dd0411 100644 --- a/modules/security/guard.sh +++ b/modules/security/guard.sh @@ -8,7 +8,7 @@ MOD_DESCRIPTION="Keeps the server at the level lss set up: daily verify and repa MOD_REQUIRES="system" MOD_OPTIONS="autofix|true|Configure modules again that fail verify or were changed outside lss time|07:00|Time of the daily check (HH:MM) -email||Send findings to this address (needs the mail-relay module or another sendmail) +email||Send findings to this address, through the mail-relay or mailserver module (or another sendmail) webhook||Send findings to this HTTPS webhook (Slack, Teams, Mattermost, Discord or ntfy) disk_threshold|90|Report file systems that are fuller than this (percent) lynis_min|0|Report a Lynis hardening index below this (needs the lynis module) @@ -92,7 +92,17 @@ EOF return } +# _guard_mail_check: the e-mail alerts need a sendmail command (mail-relay or mailserver); without one they would be +# lost without a trace. +_guard_mail_check() { + [[ -n $(opt email) ]] || return 0 + command -v sendmail >/dev/null 2>&1 && return 0 + log_warn "guard: LSS_GUARD_EMAIL is set, but this server cannot send mail yet; install mail-relay (lss install mail-relay)." + return +} + mod_verify() { + _guard_mail_check [[ -x ${GUARD_SCRIPT} ]] || die "guard: ${GUARD_SCRIPT} is missing." bash -n "${GUARD_SCRIPT}" || die "guard: ${GUARD_SCRIPT} is not a valid script." [[ $(stat -c '%a' "${GUARD_CONF}") == 600 ]] || die "guard: ${GUARD_CONF} must be mode 600." diff --git a/modules/web/tls.sh b/modules/web/tls.sh index cea674c..7ec0fa4 100644 --- a/modules/web/tls.sh +++ b/modules/web/tls.sh @@ -68,7 +68,8 @@ _tls_hook() { cat <&2 + exit 1 +} + +state() { + sed -n "s/^$2=//p" "${STATE}/$1" + return +} + +# login ADDRESS: a curl configuration line with the login of a mailbox, for curl -K - (no password in the process +# list). +login() { + printf 'user = "%s:%s"\n' "$1" "$(<"${SECRETS}/mailbox-${1/@/_}")" + return +} + +# message FROM TO SUBJECT: a test message in WORK/message. +message() { + printf 'From: %s\r\nTo: %s\r\nSubject: %s\r\nMessage-ID: <%s@lss-test>\r\nDate: %s\r\n\r\nlss mail flow test.\r\n' \ + "$1" "$2" "$3" "${RANDOM}${RANDOM}" "$(LC_ALL=C date -R)" >"${WORK}/message" + return +} + +# smtp URL FROM TO [LOGIN] [CURL OPTION...]: sends WORK/message; prints curl's error output on failure. +smtp() { + local url=$1 from=$2 to=$3 user=$4 + shift 4 + local host=${url#*://} + host=${host%%:*} + local -a args=(-sS --max-time 60 --url "${url}" --mail-from "${from}" --mail-rcpt "${to}" --upload-file "${WORK}/message") + [[ ${host} =~ ^[0-9.]+$ ]] || args+=(--resolve "${host}:${url##*:}:127.0.0.1" --cacert "${CERT}") + if [[ -n ${user} ]]; then + login "${user}" | curl "${args[@]}" "$@" -K - 2>&1 + else + curl "${args[@]}" "$@" 2>&1 + fi + return +} + +# imap MAILBOX URL-PATH [CURL OPTION...]: an IMAPS request as MAILBOX. +imap() { + local user=$1 path=$2 + shift 2 + login "${user}" | curl -sS --max-time 60 --resolve "${HOSTNAME_MAIL}:993:127.0.0.1" --cacert "${CERT}" \ + "imaps://${HOSTNAME_MAIL}:993/${path}" "$@" -K - + return +} + +# fetch_subject MAILBOX SUBJECT: waits for the message with SUBJECT in the inbox and prints it. +fetch_subject() { + local user=$1 subject=$2 uid='' i + for ((i = 0; i < 30; i++)); do + uid=$(imap "${user}" INBOX -X "UID SEARCH SUBJECT \"${subject}\"" | tr -d '\r' | awk '$1 == "*" && $2 == "SEARCH" { print $3 }') + [[ -z ${uid} ]] || break + sleep 2 + done + [[ -n ${uid} ]] || fail "the message '${subject}' did not arrive in the inbox of ${user}." + imap "${user}" "INBOX;UID=${uid}" | tr -d '\r' + return +} + +# txt NAME: the value of a TXT record in the DNS records lss printed, its strings joined. +txt() { + awk -v name="$1." '$1 == name && $2 == "TXT" { $1 = ""; $2 = ""; print }' "${DNS_FILE}" | + sed -E 's/^ +//; s/" "//g; s/^"//; s/"$//' + return +} + +# The DNS records of the main domain, as lss printed them, in Rspamd's fake DNS; the mail host gets TEST_IP. +fake_dns() { + local domain=$1 selector=$2 i + record() { + printf ' { name = "%s"; type = "%s"; replies = ["%s"]; },\n' "$1" "$2" "$3" + return + } + { + printf '# Written by tests/integration/mail-flow.sh: the DNS records of %s without public DNS.\ndns {\n fake_records = [\n' "${domain}" + record "${domain}" mx "10 ${HOSTNAME_MAIL}" + record "${HOSTNAME_MAIL}" a "${TEST_IP}" + record "${domain}" txt "$(txt "${domain}")" + record "_dmarc.${domain}" txt "$(txt "_dmarc.${domain}")" + record "${selector}._domainkey.${domain}" txt "$(txt "${selector}._domainkey.${domain}")" + printf ' ];\n}\n' + } >"${RSPAMD_OPTIONS}" + systemctl restart rspamd + for ((i = 0; i < 60; i++)); do + rspamc -h 127.0.0.1:11333 stat >/dev/null 2>&1 && return 0 + printf 'From: a@b.c\n\nping\n' | rspamc -h 127.0.0.1:11333 symbols >/dev/null 2>&1 && return 0 + sleep 1 + done + fail "Rspamd does not answer after the restart." +} + +server() { + local alice bob domain selector token mail result output ip i symbol + local -a mailboxes=() + HOSTNAME_MAIL=$(state mailserver hostname) + domain=$(state mailserver domain) + selector=$(sed -nE 's/^([a-z0-9-]+)\._domainkey\..*/\1/p' "${DNS_FILE}" | head -n 1) + mapfile -t mailboxes < <(awk '$2 == "OK" && $1 !~ /^lss-verify@/ { print $1 }' /etc/postfix/lss-mailboxes) + ((${#mailboxes[@]} >= 2)) || fail "the test settings need two mailboxes (LSS_MAILSERVER_USERS)." + alice=${mailboxes[0]} + bob=${mailboxes[1]} + token=$(date +%s) + say "the DNS records lss printed:" + sed 's/^/ /' "${DNS_FILE}" + + message "${alice}" "${bob}" "lss flow 587 ${token}" + output=$(smtp "smtp://${HOSTNAME_MAIL}:587" "${alice}" "${bob}" "${alice}" --ssl-reqd) || fail "submission on 587: ${output}" + say "${alice} sent to ${bob} on 587 (STARTTLS, login)" + message "${bob}" "${alice}" "lss flow 465 ${token}" + output=$(smtp "smtps://${HOSTNAME_MAIL}:465" "${bob}" "${alice}" "${bob}") || fail "submission on 465: ${output}" + say "${bob} sent to ${alice} on 465 (TLS, login)" + + mail=$(fetch_subject "${bob}" "lss flow 587 ${token}") + say "the message arrived in the inbox of ${bob} (IMAPS 993)" + grep -qiE "^DKIM-Signature:.*d=${domain//./\\.};" <<<"$(sed -e ':a' -e 'N;$!ba' -e 's/\n[ \t]\+/ /g' <<<"${mail}")" || + fail "the message carries no DKIM signature of ${domain}: $(grep -i '^dkim' <<<"${mail}" || true)" + grep -qi '^Authentication-Results:' <<<"${mail}" || fail "the message has no Authentication-Results header." + say "Rspamd signed it with the DKIM key of ${domain}" + fetch_subject "${alice}" "lss flow 465 ${token}" >/dev/null + say "the reply arrived in the inbox of ${alice}" + + # DKIM, SPF and DMARC of the delivered message, checked with the printed records as DNS. + fake_dns "${domain}" "${selector}" + result=$(rspamc -h 127.0.0.1:11333 --ip "${TEST_IP}" --helo "${HOSTNAME_MAIL}" --from "${alice}" symbols <<<"${mail}") + rm -f -- "${RSPAMD_OPTIONS}" + systemctl restart rspamd + for symbol in R_DKIM_ALLOW R_SPF_ALLOW DMARC_POLICY_ALLOW; do + grep -qF "${symbol}" <<<"${result}" || fail "Rspamd does not report ${symbol} for the signed message: ${result}" + done + say "DKIM, SPF and DMARC pass against the printed records (R_DKIM_ALLOW, R_SPF_ALLOW, DMARC_POLICY_ALLOW)" + + # The spam filter rejects GTUBE during the SMTP dialogue. + printf 'From: %s\r\nTo: %s\r\nSubject: spam\r\n\r\nXJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n' \ + "${alice}" "${bob}" >"${WORK}/message" + for ((i = 0; i < 20; i++)); do + output=$(smtp smtp://127.0.0.1:25 "${alice}" "${bob}" '') && fail "GTUBE was accepted on port 25." + [[ ${output} != *' 554'* && ${output} != *'(55)'* ]] || break + sleep 2 + done + [[ ${output} == *'(55)'* || ${output} == *554* ]] || fail "GTUBE was not rejected as spam: ${output}" + say "GTUBE is rejected on port 25" + + # What must be refused: submission without login, a wrong password, relaying for strangers. + message "${alice}" "${bob}" "lss flow refused ${token}" + output=$(smtp "smtp://${HOSTNAME_MAIL}:587" "${alice}" "${bob}" '' --ssl-reqd) && fail "submission without a login was accepted." + printf 'user = "%s:wrong-password"\n' "${alice}" | + curl -sS --max-time 60 --url "smtp://${HOSTNAME_MAIL}:587" --resolve "${HOSTNAME_MAIL}:587:127.0.0.1" --cacert "${CERT}" \ + --ssl-reqd --mail-from "${alice}" --mail-rcpt "${bob}" --upload-file "${WORK}/message" -K - >/dev/null 2>&1 && + fail "submission with a wrong password was accepted." + ip=$(ip -4 -o route get 192.0.2.1 | sed -n 's/.* src \([0-9.]*\).*/\1/p') + output=$(smtp "smtp://${ip}:25" "${alice}" stranger@example.net '') && fail "port 25 relays for a stranger from ${ip}." + say "submission without login or with a wrong password and relaying from ${ip} are refused" + return +} + +webmail() { + local site user page token status + site=$(sed -nE 's/.*server_name ([^ ;]+).*/\1/p' /etc/nginx/lss/sites/webmail.conf 2>/dev/null | head -n 1) + [[ -n ${site} ]] || site=$(state mailserver hostname) + user=$(awk '$2 == "OK" && $1 !~ /^lss-verify@/ { print $1; exit }' /etc/postfix/lss-mailboxes) + local -a curl_args=(-ks --max-time 60 --resolve "${site}:443:127.0.0.1" -b "${WORK}/cookies" -c "${WORK}/cookies") + page=$(curl "${curl_args[@]}" "https://${site}/") + token=$(grep -oE 'name="_token" value="[^"]+"' <<<"${page}" | head -n 1 | sed -E 's/.*value="([^"]+)"/\1/') + [[ -n ${token} ]] || fail "the Roundcube login page has no form token." + cp "${SECRETS}/mailbox-${user/@/_}" "${WORK}/password" + status=$(curl "${curl_args[@]}" -o /dev/null -w '%{http_code} %{redirect_url}' -d _task=login -d _action=login \ + -d _timezone=UTC --data-urlencode "_token=${token}" --data-urlencode "_user=${user}" \ + --data-urlencode "_pass@${WORK}/password" "https://${site}/?_task=login") + [[ ${status} == 302*'_task=mail'* ]] || fail "the Roundcube login of ${user} answers ${status} instead of a redirect to the mail view." + page=$(curl "${curl_args[@]}" "https://${site}/?_task=mail&_mbox=INBOX") + grep -qF 'task-mail' <<<"${page}" || fail "Roundcube does not show the mailbox of ${user} after the login." + say "${user} logged in to Roundcube on https://${site}/ and sees the inbox" + return +} + +relay() { + local box hostname i + hostname=$(state mail-relay hostname) + box=/var/mail/root + [[ -d /var/mail ]] || box=/var/spool/mail/root + timeout 900 systemctl start lss-guard.service || true + for ((i = 0; i < 60; i++)); do + if grep -qF 'Subject: [lss guard]' "${box}" 2>/dev/null; then + say "the guard's summary arrived in ${box} through mail-relay (${hostname})" + grep -F -A 3 'Subject: [lss guard]' "${box}" | head -n 4 + return 0 + fi + sleep 2 + done + postqueue -p || true + journalctl -u lss-guard.service -n 30 --no-pager || true + fail "the guard's summary did not arrive in ${box}." +} + +case ${1:-} in +server) server ;; +webmail) webmail ;; +relay) relay ;; +*) + echo "usage: mail-flow.sh server|webmail|relay" >&2 + exit 2 + ;; +esac diff --git a/tests/integration/run.sh b/tests/integration/run.sh index b3e9529..2c91e81 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -177,6 +177,20 @@ resources_report() { return } +# The mail modules are used, not only installed: mail between two mailboxes, a webmail login, the guard's summary +# through mail-relay (tests/integration/mail-flow.sh, without public DNS). +mail_flow() { + local mode + local -a modes=() + [[ " $* " != *" mailserver "* ]] || modes+=(server) + [[ " $* " != *" webmail "* ]] || modes+=(webmail) + [[ " $* " != *" mail-relay "* || " $* " != *" guard "* ]] || modes+=(relay) + for mode in "${modes[@]}"; do + in_target "bash /opt/lss-src/tests/integration/mail-flow.sh ${mode}" + done + return +} + trap stop_target EXIT start_target @@ -243,6 +257,7 @@ for item in "$@"; do in_target "${LSS_RUN} --yes --no-color install ${set[*]}" in_target "${LSS_RUN} --yes --no-color verify" resources_report "${system} ${set[*]}" "${before}" "${started}" + mail_flow "${set[@]}" install_again "${set[@]}" if [[ ${LSS_TEST_KEEP:-0} != 1 ]]; then for ((i = ${#set[@]} - 1; i >= 0; i--)); do diff --git a/tests/integration/settings.conf b/tests/integration/settings.conf index 3c86208..7f10778 100644 --- a/tests/integration/settings.conf +++ b/tests/integration/settings.conf @@ -43,3 +43,13 @@ LSS_NETDATA_ACCESS=vpn # Network modules: one peer with a throwaway public key (any 32 bytes are a valid key; nobody holds a private half). LSS_WIREGUARD_PEERS=ci:xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=:10.66.0.2 + +# Mail modules: two domains with a mailbox each and an alias. The certificate is self-signed for mail.example.com; +# tests/integration/mail-flow.sh checks DKIM, SPF and DMARC against the DNS records lss prints, without public DNS. +LSS_MAILSERVER_DOMAINS=example.com example.org +LSS_MAILSERVER_USERS=alice bob@example.org +LSS_MAILSERVER_ALIASES=info:alice,bob@example.org +# The guard sends its daily summary to root, which mail-relay delivers to /var/mail/root (the container has no domain, +# so mail-relay names it lss-test.localdomain). +LSS_GUARD_EMAIL=root@lss-test.localdomain +LSS_GUARD_DAILY_SUMMARY=true diff --git a/tests/unit/helpers.bash b/tests/unit/helpers.bash index 8fdb399..028bb71 100644 --- a/tests/unit/helpers.bash +++ b/tests/unit/helpers.bash @@ -22,7 +22,7 @@ lss_test_setup() { export OWNER : >"${STUB_LOG}" local library - for library in log os config files template secrets state pkg repo fetch service firewall container web monitoring module ui; do + for library in log os config files template secrets state pkg repo fetch service firewall container web mail monitoring module ui; do # shellcheck source=/dev/null source "${REPO_ROOT}/lib/${library}.sh" done diff --git a/tests/unit/mail.bats b/tests/unit/mail.bats new file mode 100644 index 0000000..ed847a7 --- /dev/null +++ b/tests/unit/mail.bats @@ -0,0 +1,336 @@ +#!/usr/bin/env bats +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# The mail modules: the checks of their settings, the Postfix, Dovecot and Rspamd configuration they write, the DNS +# records lss prints for the mail server, and the guard's warning when it cannot send its mail. + +setup() { + load helpers + lss_test_setup + use_os debian-13 + export TMPDIR=${BATS_TEST_TMPDIR} + WRITES=${BATS_TEST_TMPDIR}/writes + : >"${WRITES}" + # shellcheck disable=SC2329 # called by the functions under test + file_write() { + printf '== %s %s %s\n' "$1" "$2" "$3" >>"${WRITES}" + cat >>"${WRITES}" + LSS_FILE_CHANGED=${TEST_FILE_CHANGED:-1} + } + # shellcheck disable=SC2329 # called by the functions under test + id() { + case $1 in + -u | -g) printf '990\n' ;; + *) command id "$@" ;; + esac + } +} + +load_module() { + local file + file=$(module_file "$1") + export LSS_MODULE=$1 + # shellcheck source=/dev/null + source "${file}" + return +} + +# --- lib/mail.sh ------------------------------------------------------------------------------------------------- + +@test "the mail host name keeps a full name and adds .localdomain to a short one" { + [ "$(mail_fqdn mail.example.com)" = mail.example.com ] + [ "$(mail_fqdn Server)" = server.localdomain ] + mail_valid_domain example.com + run ! mail_valid_domain localhost + run ! mail_valid_domain 'example.com;reboot' +} + +@test "master.cf runs nothing chrooted and carries every service Postfix needs" { + run mail_master_cf + [[ ${output} == *pickup*cleanup*qmgr*smtp*local*virtual*lmtp*postlogd* ]] + run awk 'NF >= 8 && $5 != "n" && $1 != "-o" { print }' <<<"${output}" + [ -z "${output}" ] +} + +@test "the Postfix unit is the instance postfix@- on Debian and postfix on the RHEL family" { + [ "$(mail_postfix_unit)" = 'postfix@-' ] + use_os rocky-9 + [ "$(mail_postfix_unit)" = postfix ] + [ "$(mail_ca_bundle)" = /etc/pki/tls/certs/ca-bundle.crt ] +} + +# --- mail-relay -------------------------------------------------------------------------------------------------- + +@test "mail-relay accepts a smarthost with login and refuses bad hosts, ports and addresses" { + load_module mail-relay + config_set LSS_MAIL_RELAY_RELAYHOST smtp.example.com:587 + config_set LSS_MAIL_RELAY_USER relay@example.com + config_set LSS_MAIL_RELAY_PASSWORD secret + config_set LSS_MAIL_RELAY_FROM server@example.com + mod_check + config_set LSS_MAIL_RELAY_RELAYHOST 'smtp.example.com:99999' + run mod_check + [[ ${output} == *"'99999' is not a port number."* ]] + config_set LSS_MAIL_RELAY_RELAYHOST '' + run mod_check + [[ ${output} == *"LSS_MAIL_RELAY_USER and LSS_MAIL_RELAY_PASSWORD need LSS_MAIL_RELAY_RELAYHOST."* ]] + config_set LSS_MAIL_RELAY_USER '' + config_set LSS_MAIL_RELAY_PASSWORD '' + config_set LSS_MAIL_RELAY_FORWARD 'admin at example.com' + run mod_check + [[ ${output} == *"'admin at example.com' is not an e-mail address."* ]] +} + +@test "mail-relay with a smarthost on 465 checks its certificate, logs in and rewrites the sender" { + load_module mail-relay + config_set LSS_MAIL_RELAY_RELAYHOST smtp.example.com:465 + config_set LSS_MAIL_RELAY_USER relay@example.com + config_set LSS_MAIL_RELAY_FROM server@example.com + run _relay_main host.example.com + [[ ${output} == *$'\nrelayhost = [smtp.example.com]:465\n'* ]] + [[ ${output} == *$'\nsmtp_tls_security_level = secure\n'* ]] + [[ ${output} == *$'\nsmtp_tls_wrappermode = yes\n'* ]] + [[ ${output} == *$'\nsmtp_sasl_password_maps = texthash:/etc/postfix/lss-sasl-passwd\n'* ]] + [[ ${output} == *$'\nsmtp_generic_maps = texthash:/etc/postfix/lss-generic'* ]] + [[ ${output} == *$'\ninet_interfaces = loopback-only\n'* ]] + [[ ${output} == *'transport_maps = inline:{ lss-verify@host.example.com=discard: }'* ]] + config_set LSS_MAIL_RELAY_TLS_VERIFY false + run _relay_main host.example.com + [[ ${output} == *$'\nsmtp_tls_security_level = encrypt\n'* ]] +} + +@test "mail-relay without a smarthost delivers directly with opportunistic TLS, DANE with dns-resolver" { + load_module mail-relay + run _relay_main host.example.com + [[ ${output} == *$'\nrelayhost = \n'* ]] + [[ ${output} == *$'\nsmtp_tls_security_level = may\n'* ]] + [[ ${output} != *smtp_sasl* ]] + state_set dns-resolver status installed + run _relay_main host.example.com + [[ ${output} == *$'\nsmtp_tls_security_level = dane\nsmtp_tls_mandatory_protocols'* ]] + [[ ${output} == *$'\nsmtp_dns_support_level = dnssec'* ]] +} + +@test "mail-relay keeps the smarthost password in the secrets and writes it for root only" { + load_module mail-relay + config_set LSS_MAIL_RELAY_RELAYHOST smtp.example.com + config_set LSS_MAIL_RELAY_USER relay@example.com + config_set LSS_MAIL_RELAY_PASSWORD s3cret + config_set LSS_MAIL_RELAY_FORWARD admin@example.com + _relay_tables host.example.com + [ "$(secret_get mail-relay-password)" = s3cret ] + grep -qxF "== 0600 root:root /etc/postfix/lss-sasl-passwd" "${WRITES}" + grep -qxF '[smtp.example.com]:25 relay@example.com:s3cret' "${WRITES}" + grep -qxF 'root: admin@example.com' "${WRITES}" +} + +# --- mailserver -------------------------------------------------------------------------------------------------- + +@test "mailserver needs a domain and refuses bad mailboxes, aliases and settings" { + load_module mailserver + run mod_check + [[ ${output} == *"LSS_MAILSERVER_DOMAINS is empty"* ]] + config_set LSS_MAILSERVER_DOMAINS 'example.com example.org' + config_set LSS_MAILSERVER_USERS 'alice bob@example.org' + config_set LSS_MAILSERVER_ALIASES 'info:alice,bob@example.org' + mod_check + config_set LSS_MAILSERVER_USERS 'alice carol@example.net' + run mod_check + [[ ${output} == *"carol@example.net is not at one of the mail domains"* ]] + config_set LSS_MAILSERVER_USERS 'alice alice@example.com' + run mod_check + [[ ${output} == *"names alice@example.com twice."* ]] + config_set LSS_MAILSERVER_USERS 'lss-verify' + run mod_check + [[ ${output} == *"lss-verify is reserved"* ]] + config_set LSS_MAILSERVER_USERS alice + config_set LSS_MAILSERVER_ALIASES 'alice:bob@example.org' + run mod_check + [[ ${output} == *"alice@example.com is a mailbox or another alias already."* ]] + config_set LSS_MAILSERVER_ALIASES '' + config_set LSS_MAILSERVER_HOSTNAME example.com + run mod_check + [[ ${output} == *"must differ from the mail domains"* ]] + config_set LSS_MAILSERVER_HOSTNAME '' + config_set LSS_MAILSERVER_DMARC_POLICY strict + run mod_check + [[ ${output} == *"must be none, quarantine or reject."* ]] + config_set LSS_MAILSERVER_DMARC_POLICY reject + config_set LSS_MAILSERVER_SPAM_HEADER 20 + run mod_check + [[ ${output} == *"LSS_MAILSERVER_SPAM_HEADER must be lower than LSS_MAILSERVER_SPAM_REJECT."* ]] +} + +@test "postmaster and abuse of every domain go to the first mailbox unless an alias names them" { + load_module mailserver + config_set LSS_MAILSERVER_DOMAINS 'example.com example.org' + config_set LSS_MAILSERVER_USERS 'alice bob@example.org' + config_set LSS_MAILSERVER_ALIASES 'abuse@example.org:bob@example.org team:alice,bob@example.org' + _ms_parse + [ "${MS_ADDRESSES[*]}" = 'alice@example.com bob@example.org' ] + local key + key=postmaster@example.com + [ "${MS_ALIAS[${key}]}" = alice@example.com ] + key=postmaster@example.org + [ "${MS_ALIAS[${key}]}" = alice@example.com ] + key=abuse@example.org + [ "${MS_ALIAS[${key}]}" = bob@example.org ] + key=team@example.com + [ "${MS_ALIAS[${key}]}" = alice@example.com,bob@example.org ] + [ "$(_ms_hostname)" = mail.example.com ] + [ "$(_ms_secret bob@example.org)" = mailbox-bob_example.org ] +} + +@test "the Postfix tables let each login send as its own address and its aliases only" { + load_module mailserver + config_set LSS_MAILSERVER_DOMAINS example.com + config_set LSS_MAILSERVER_USERS 'alice bob' + config_set LSS_MAILSERVER_ALIASES 'team:alice,bob,someone@example.net' + _ms_parse + _ms_tables + grep -qxF 'lss-verify@example.com OK' "${WRITES}" + grep -qxF 'team@example.com alice@example.com,bob@example.com,someone@example.net' "${WRITES}" + grep -qxF 'alice@example.com alice@example.com' "${WRITES}" + grep -qxF 'lss-verify@example.com REJECT' "${WRITES}" + grep -qxF 'root: postmaster@example.com' "${WRITES}" + run grep -c '^team@example.com ' "${WRITES}" + [ "${output}" = 2 ] +} + +@test "main.cf filters every message through Rspamd and allows login only with TLS" { + load_module mailserver + config_set LSS_MAILSERVER_DOMAINS 'example.com example.org' + _ms_parse + run _ms_main mail.example.com /etc/lss/tls/mail + [[ ${output} == *$'\nvirtual_mailbox_domains = example.com example.org\n'* ]] + [[ ${output} == *$'\nsmtpd_milters = inet:127.0.0.1:11332\n'* ]] + [[ ${output} == *$'\nmilter_default_action = tempfail\n'* ]] + [[ ${output} == *$'\nsmtpd_tls_auth_only = yes\n'* ]] + [[ ${output} == *$'\nsmtpd_tls_chain_files = /etc/lss/tls/mail/privkey.pem, /etc/lss/tls/mail/fullchain.pem\n'* ]] + [[ ${output} == *$'\nmessage_size_limit = 26214400\n'* ]] + run _ms_master + [[ ${output} == *$'submission inet'*'-o smtpd_tls_security_level=encrypt'* ]] + [[ ${output} == *$'465 inet'*'-o smtpd_tls_wrappermode=yes'* ]] + [[ ${output} == *$'127.0.0.1:10587 inet'*'-o smtpd_tls_security_level=none'* ]] + [[ ${output} == *'-o smtpd_sender_restrictions=reject_sender_login_mismatch'* ]] +} + +@test "dovecot.conf follows the syntax of Dovecot 2.3 or 2.4" { + load_module mailserver + config_set LSS_MAILSERVER_DOMAINS example.com + _ms_parse + # shellcheck disable=SC2329 # called by _ms_dovecot_24 + dovecot() { printf '2.4.1 (abc)\n'; } + run _ms_dovecot_conf /etc/lss/tls/mail + [[ ${output} == *'dovecot_config_version = 2.4.0'* ]] + [[ ${output} == *'ssl_server_key_file = /etc/lss/tls/mail/privkey.pem'* ]] + [[ ${output} == *'passwd_file_path = /etc/dovecot/lss-users'* ]] + [[ ${output} != *mail_location* ]] + # shellcheck disable=SC2329 # called by _ms_dovecot_24 + dovecot() { printf '2.3.21 (47349e2482)\n'; } + run _ms_dovecot_conf /etc/lss/tls/mail + [[ ${output} == *'ssl_key = "${MS_USERS}" + grep -qE '^alice@example.com:\{SHA512-CRYPT\}\$6\$[^:]+:990:990::/var/vmail/example.com/alice::$' "${MS_USERS}" + grep -qE '^lss-verify@example.com:.*::nologin=y reason=lss-verify$' "${MS_USERS}" + run _ms_users_file + [ "${output}" = "$(<"${MS_USERS}")" ] + secret_set mailbox-alice_example.com changed + run _ms_users_file + [ "$(grep '^alice' <<<"${output}")" != "$(grep '^alice' "${MS_USERS}")" ] + run grep -c 'changed' "${MS_USERS}" + [ "${output}" = 0 ] +} + +@test "the DNS records name the mail host, SPF, the DKIM key, DMARC and the reverse DNS" { + load_module mailserver + config_set LSS_MAILSERVER_DOMAINS example.com + config_set LSS_MAILSERVER_DMARC_POLICY reject + local key + key=$(printf 'A%.0s' {1..400}) + run _ms_dns_records mail.example.com 203.0.113.5 2001:db8::25 "example.com=${key}" + [[ ${output} == *$'mail.example.com. A 203.0.113.5\n'* ]] + [[ ${output} == *$'mail.example.com. AAAA 2001:db8::25\n'* ]] + [[ ${output} == *$'example.com. MX 10 mail.example.com.\n'* ]] + [[ ${output} == *$'example.com. TXT "v=spf1 mx -all"\n'* ]] + [[ ${output} == *'lss._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=AAAA'*'" "AAAA'* ]] + [[ ${output} == *'_dmarc.example.com. TXT "v=DMARC1; p=reject; adkim=r; aspf=r; rua=mailto:postmaster@example.com"'* ]] + [[ ${output} == *'5.113.0.203.in-addr.arpa. PTR mail.example.com.'* ]] + [[ ${output} == *'5.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa. PTR mail.example.com.'* ]] + run _ms_dns_records mail.example.com '' '' "example.com=${key}" + [[ ${output} != *PTR* && ${output} != *' A '* ]] +} + +@test "the reverse DNS name of IPv6 addresses expands every form" { + load_module mailserver + [ "$(_ms_ptr6 ::1)" = "1.$(printf '0.%.0s' {1..31})ip6.arpa." ] + [ "$(_ms_ptr6 2001:db8:0:0:0:0:0:1)" = "$(_ms_ptr6 2001:db8::1)" ] + [ "$(_ms_ptr6 fe80::)" = "$(printf '0.%.0s' {1..28})0.8.e.f.ip6.arpa." ] +} + +@test "a TXT value longer than 255 characters is split into strings" { + load_module mailserver + run _ms_txt "$(printf 'x%.0s' {1..600})" + [[ ${output} =~ ^\"x{255}\"\ \"x{255}\"\ \"x{90}\"$ ]] +} + +# --- webmail ----------------------------------------------------------------------------------------------------- + +@test "webmail checks its host names and settings, and names the checksum of its release" { + load_module webmail + mod_check + config_set LSS_WEBMAIL_DOMAINS 'mail.example.com bad_name' + run mod_check + [[ ${output} == *"'bad_name' is not a host name"* ]] + config_set LSS_WEBMAIL_DOMAINS '' + config_set LSS_WEBMAIL_PRODUCT_NAME "Mail'); system('id" + run mod_check + [[ ${output} == *"LSS_WEBMAIL_PRODUCT_NAME may contain"* ]] + config_set LSS_WEBMAIL_PRODUCT_NAME Webmail + config_set LSS_WEBMAIL_SESSION_LIFETIME 2 + run mod_check + [[ ${output} == *"between 5 and 1440 minutes."* ]] +} + +@test "the Roundcube configuration talks to the local mail server and keeps sessions strict" { + load_module webmail + state_set mailserver domain example.com + run _rc_config + [[ ${output} == *"\$config['imap_host'] = '127.0.0.1:143';"* ]] + [[ ${output} == *"\$config['smtp_host'] = '127.0.0.1:10587';"* ]] + [[ ${output} == *"\$config['username_domain'] = 'example.com';"* ]] + [[ ${output} == *"\$config['des_key'] = '$(secret_get webmail-des-key)';"* ]] + [[ ${output} == *"\$config['enable_installer'] = false;"* ]] + [[ ${output} == *"\$config['identities_level'] = 3;"* ]] + [[ ${output} == *"\$config['session_lifetime'] = 30;"* ]] + state_set mailserver hostname mail.example.com + [ "$(_rc_domains)" = mail.example.com ] +} + +# --- guard ------------------------------------------------------------------------------------------------------- + +@test "the guard warns when it has an address but no sendmail to reach it" { + load_module guard + config_set LSS_GUARD_EMAIL admin@example.com + # A PATH with date (for the log line) and without sendmail. + mkdir -p "${BATS_TEST_TMPDIR}/bin" + ln -s "$(command -v date)" "${BATS_TEST_TMPDIR}/bin/date" + PATH=${BATS_TEST_TMPDIR}/bin run _guard_mail_check + [[ ${output} == *"LSS_GUARD_EMAIL is set, but this server cannot send mail yet; install mail-relay"* ]] + config_set LSS_GUARD_EMAIL '' + run _guard_mail_check + [ -z "${output}" ] +} diff --git a/tests/unit/web.bats b/tests/unit/web.bats index 2acfc5a..978d3a7 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -443,6 +443,9 @@ EOF [[ ${output} == *"root ${WEB_ACME_ROOT};"* ]] [[ ${output} == *'http2 on;'* ]] [[ ${output} == *"fastcgi_pass unix:${WEB_PHP_SOCKET};"* ]] + # PATH_INFO after the script name (/static.php/skins/...), as Roundcube and Nextcloud use it. + [[ ${output} == *'location ~ [^/]\.php(/|$) {'* ]] + [[ ${output} == *'fastcgi_param PATH_INFO $fastcgi_path_info;'* ]] [[ ${output} == *'location ~ /\.(?!well-known)'* ]] [[ ${output} == *'Strict-Transport-Security "max-age=63072000" always;'* ]] } From d0a0de880e1d775d96ef8b89238f7fbd59d65398 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 06:29:17 +0200 Subject: [PATCH 02/11] docs: mail modules in the README, changelog and hardening guide Users need to know which DNS records the mail server needs, how to read the mailbox passwords and which limits apply (port 25 blocked by many providers), and the hardening guide lists what every module changes. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- CHANGELOG.md | 5 +++++ README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++-- docs/hardening.md | 3 +++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9765ae..e5aa3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,11 @@ now or later. the time module serves the WireGuard network or everyone, rate-limited). - Profile `devops` (minimal-secure, Docker and the monitoring stack); `webhosting` adds restic and db-dumps, `all` every new module. +- Mail modules: `mail-relay` (send-only Postfix on the loopback addresses, through a smarthost with a checked + certificate or directly with opportunistic TLS or DANE), `mailserver` (Postfix, Dovecot and Rspamd: submission with + TLS and login only, senders tied to their login, spam filter, DKIM signing, SPF, DKIM and DMARC checks; it prints the + MX, SPF, DKIM, DMARC and PTR records to set) and `webmail` (checksum-verified Roundcube on the web server). guard + sends its e-mail through them. Profile `office`; `all` adds mailserver and webmail. - guard checks the age of the last backup (`LSS_GUARD_BACKUP_MAX_AGE`), runs a restore test every week and exports its results for Prometheus through the node exporter. - The services of the backup, monitoring and network modules run with a systemd sandbox drop-in; a service that diff --git a/README.md b/README.md index b4d4da2..e480e75 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ first password with `lss secrets show pgadmin-admin-password`), all on the admin together with `docker`). See [Containers](#containers). The [hardening guide](docs/hardening.md) lists what each security module changes, the CIS Benchmark areas it covers -and where lss deliberately deviates. Mail follows as a further module group. +and where lss deliberately deviates. **Backup:** `restic` (daily, encrypted backups to a local path, SFTP or S3) and `db-dumps` (dumps of the databases before each backup). See [Backup](#backup). @@ -192,6 +192,9 @@ before each backup). See [Backup](#backup). **Network:** `wireguard` (VPN server), `dns-resolver` (Unbound with DNSSEC), `ntp-server` (chrony serves the time). See [WireGuard, DNS and NTP](#wireguard-dns-and-ntp). +**Mail:** `mail-relay` (the server sends its own mail, through a smarthost or directly) or `mailserver` (Postfix, +Dovecot and Rspamd for your domains), `webmail` (Roundcube on the web server). See [Mail](#mail). + ### Web sites One web server owns ports 80 and 443. It answers only for the sites you set up; requests for any other host name get @@ -310,6 +313,54 @@ resolvers, over TLS with `LSS_DNS_RESOLVER_FORWARD_TLS=true`. `ntp-server` opens chrony of the `time` module to the WireGuard network (or to everyone with `LSS_NTP_SERVER_ACCESS=public`), with rate limiting. +### Mail + +`mail-relay` is for servers that only send: mail from root, cron, the guard and applications goes out through +`LSS_MAIL_RELAY_RELAYHOST` (STARTTLS on 587 or TLS on 465, the certificate checked, `LSS_MAIL_RELAY_USER` and the +password in the lss secrets) or, without a smarthost, straight to the recipients' servers with opportunistic TLS +(DANE when `dns-resolver` is installed). Postfix listens on 127.0.0.1 and ::1 only. Mail for root stays in +`/var/mail/root` unless `LSS_MAIL_RELAY_FORWARD` names an address. Most smarthosts accept only their own sender +addresses; set `LSS_MAIL_RELAY_FROM`. + +```bash +LSS_MAIL_RELAY_RELAYHOST=smtp.example.com:587 +LSS_MAIL_RELAY_USER=server1@example.com +LSS_MAIL_RELAY_PASSWORD=... +LSS_MAIL_RELAY_FROM=server1@example.com +LSS_MAIL_RELAY_FORWARD=admin@example.com +``` + +`mailserver` receives and delivers mail for `LSS_MAILSERVER_DOMAINS`: Postfix on port 25, submission on 587 +(STARTTLS) and 465 (TLS) with login only and the sender tied to the login, Dovecot with IMAPS on 993, Rspamd as spam +filter (reject from score 15, a header from 6) that signs outgoing mail with DKIM and checks SPF, DKIM and DMARC of +incoming mail. Mailboxes are `LSS_MAILSERVER_USERS`, aliases `LSS_MAILSERVER_ALIASES`; postmaster and abuse of every +domain go to the first mailbox. Each password is a secret: + +```bash +LSS_MAILSERVER_DOMAINS=example.com example.org +LSS_MAILSERVER_USERS=alice bob@example.org +LSS_MAILSERVER_ALIASES=info:alice,bob@example.org +sudo lss secrets show mailbox-alice_example.com +``` + +lss cannot set DNS records, so it prints the ones the domains need after every change and keeps them in +`/etc/lss/mailserver-dns.txt`: MX, A and AAAA of the mail host (`mail.` unless +`LSS_MAILSERVER_HOSTNAME`), SPF, the DKIM key (selector `lss`), DMARC (`LSS_MAILSERVER_DMARC_POLICY`, quarantine) +and the reverse DNS (PTR) the hosting provider has to set for the server's addresses. Without them other servers +reject or file the mail as spam. The certificate is self-signed until `LSS_MAILSERVER_CERTIFICATE=default` takes the +one of the `tls` module (with the mail host in `LSS_TLS_DOMAINS`); Postfix and Dovecot reload on every renewal. +Many providers block outgoing port 25; ask for it to be opened or relay through a smarthost of your provider. + +`webmail` runs Roundcube as a site of the web server (`LSS_WEBMAIL_DOMAINS`, by default the mail host), with the +certificate of the `tls` module when it is installed. It talks to Dovecot and Postfix on 127.0.0.1 only. The profile +`office` installs `minimal-secure`, nginx with `tls` and `php`, `mailserver`, `webmail` and `restic`: + +```bash +sudo lss install --profile office --set LSS_ADMIN_USER_NAME=alice --set LSS_ADMIN_USER_GITHUB_USER=alice-on-github --set LSS_MAILSERVER_DOMAINS=example.com --set LSS_MAILSERVER_USERS=alice --set LSS_TLS_DOMAINS=mail.example.com +``` + +`restic` backs up the mailboxes (`/var/vmail`), the DKIM keys and the Roundcube database by default. + ## Guard The `guard` module checks the server every day: it runs `lss verify`, repairs modules that fail or whose files were @@ -318,7 +369,8 @@ falling Lynis index, vulnerable packages (debsecan on Debian, `dnf updateinfo` o images with HIGH or CRITICAL vulnerabilities (Trivy, with `docker` or `podman`). It passes on what the daily AIDE, rkhunter and ClamAV runs found, and reports more failed SSH logins than `LSS_GUARD_LOGIN_THRESHOLD` with their main sources. Successful SSH logins and active fail2ban or CrowdSec bans are -listed as notes. Findings go to a webhook (Slack, Teams, Mattermost, Discord, ntfy) or by e-mail. +listed as notes. Findings go to a webhook (Slack, Teams, Mattermost, Discord, ntfy) or by e-mail to +`LSS_GUARD_EMAIL`, which needs `mail-relay` or `mailserver`; the guard warns when the server cannot send mail. With `restic` installed, the guard reports a last successful backup older than `LSS_GUARD_BACKUP_MAX_AGE` hours (30) and runs a restore test every week. With `node-exporter` installed, the number of findings goes to Prometheus as diff --git a/docs/hardening.md b/docs/hardening.md index 50000b8..bde6c45 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -53,6 +53,9 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D | `k3s` | Pinned, checksum-verified binary; secrets encrypted at rest, kubeconfig 0600, `protect-kernel-defaults` with the kernel values the kubelet expects, Pod Security admission (`restricted` everywhere except `kube-system`), an audit log (metadata, 30 days), idle streaming connections closed after 5 minutes, finished pods collected; k3s-selinux on the RHEL family; the API on 127.0.0.1 unless opened; pod traffic to the host only from the pod network on its bridge | CIS Kubernetes Benchmark as applied by the k3s CIS Hardening Guide (control plane, kubelet, Pod Security) | | `guard` with a container engine | Trivy scans every image daily and reports HIGH and CRITICAL vulnerabilities per image | CIS Docker, section 4 (images) | | `haproxy` | Statistics only on 127.0.0.1, admin socket readable by root only, chroot, systemd sandbox, ports in the firewall only for services that are not `local` | (operations) | +| `mail-relay` | Postfix on 127.0.0.1 and ::1 only, no relaying for anyone else; TLS to the smarthost with its certificate checked against the system CAs (DANE or opportunistic TLS without one), the smarthost password in a 0600 table; no delivery to programs, files or `~/.forward`; no chroot, a systemd sandbox instead | (operations) | +| `mailserver` | Postfix: no open relay, submission on 587 and 465 only with TLS 1.2 or later and a login, the sender address tied to the login, login attempts rate-limited, no VRFY, strict HELO and recipient checks; Dovecot: IMAPS only (plain IMAP on 127.0.0.1 for webmail), SHA-512-crypt passwords in a 0640 file, mailboxes in `/var/vmail` (0700, own user); Rspamd: rejects spam, signs with DKIM (RSA 2048, keys 0640), checks SPF, DKIM and DMARC, its web interface off; every service in a systemd sandbox | (operations; Mozilla TLS guidelines) | +| `webmail` | Checksum-verified Roundcube without the installer, configuration readable by root and PHP-FPM only, a generated DES key, IMAP and SMTP on 127.0.0.1 only, strict SameSite session cookies bound to the client address, HTTPS only through the web server | OWASP Session Management | ## Deliberate deviations From b372130aef557b41c300079d26157779a429e7b8 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 09:54:09 +0200 Subject: [PATCH 03/11] fix(mail): Postfix 3.10 unit, Postfix 3.5 TLS syntax, texthash aliases The first CI run of the mail group showed four faults: - Debian 13 and Ubuntu 26.04 ship Postfix 3.10, whose postfix.service runs the master itself; postfix@- there asserts /etc/postfix-/ and never starts. lss now reads the unit file to pick the unit. - Postfix 3.5 on Rocky 9 does not know ">=TLSv1.2" and switched TLS off for submission; the exclusion list works on every version. - texthash tables take "key value" lines, so the "key: value" lines of the alias tables never matched (Postfix warned about each one). - The DKIM check of verify looked for the key in one piece, but the record holds it as 255-character strings. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/mail.sh | 25 +++++++++++++++++++------ modules/mail/mail-relay.sh | 13 +++++++------ modules/mail/mailserver.sh | 20 +++++++++++--------- tests/unit/mail.bats | 13 +++++++++++-- 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/lib/mail.sh b/lib/mail.sh index 457de3d..9c9ed2e 100644 --- a/lib/mail.sh +++ b/lib/mail.sh @@ -38,10 +38,25 @@ mail_valid_domain() { return } -# mail_postfix_unit: the unit that runs the Postfix master. Debian and Ubuntu start it as the instance postfix@-; -# postfix.service there only groups the instances. +# mail_postfix_unit: the unit that runs the Postfix master. Debian and Ubuntu up to Postfix 3.9 start it as the +# instance postfix@-, and postfix.service there only groups the instances (ExecStart=/bin/true); from Debian 13 and +# Ubuntu 26.04 on (Postfix 3.10), and on the RHEL family, postfix.service runs the master itself. mail_postfix_unit() { - if os_is_debian; then printf 'postfix@-'; else printf 'postfix'; fi + local fragment + if os_is_debian; then + fragment=$(systemctl show -P FragmentPath postfix.service 2>/dev/null) || fragment='' + if [[ -z ${fragment} || ! -r ${fragment} ]] || grep -q '^ExecStart=/bin/true' "${fragment}"; then + printf 'postfix@-' + return + fi + fi + printf 'postfix' + return +} + +# mail_postfix_template: the unit file the sandbox drop-in of the Postfix master belongs to. +mail_postfix_template() { + if [[ $(mail_postfix_unit) == postfix@- ]]; then printf 'postfix@.service'; else printf 'postfix.service'; fi return } @@ -102,9 +117,7 @@ EOF # mail_postfix_sandbox [DIRECTIVE...]: the systemd sandbox of the Postfix master and its daemons. They write the queue, # their state and the local mailboxes only. Postfix needs /dev/log of the host for syslog, so /dev stays. mail_postfix_sandbox() { - local unit=postfix.service - os_is_debian && unit=postfix@.service - svc_sandbox "${unit}" PrivateDevices=no ReadWritePaths=/var/spool/postfix ReadWritePaths=/var/lib/postfix \ + svc_sandbox "$(mail_postfix_template)" PrivateDevices=no ReadWritePaths=/var/spool/postfix ReadWritePaths=/var/lib/postfix \ "$@" return } diff --git a/modules/mail/mail-relay.sh b/modules/mail/mail-relay.sh index e878f19..80df5ff 100644 --- a/modules/mail/mail-relay.sh +++ b/modules/mail/mail-relay.sh @@ -116,8 +116,8 @@ allow_mail_to_files = transport_maps = inline:{ ${MAIL_VERIFY_LOCALPART}@${hostname}=discard: } relayhost = ${nexthop} smtp_tls_security_level = ${level} -smtp_tls_mandatory_protocols = >=TLSv1.2 -smtp_tls_protocols = >=TLSv1.2 +smtp_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 +smtp_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 smtp_tls_CAfile = $(mail_ca_bundle) smtp_tls_loglevel = 1 EOF @@ -149,8 +149,9 @@ _relay_tables() { local hostname=$1 forward from password forward=$(opt forward) { - printf '# Managed by lss (module mail-relay).\npostmaster: root\nabuse: root\n' - [[ -z ${forward} ]] || printf 'root: %s\n' "${forward}" + # texthash reads "key value" lines, not the "key: value" lines of aliases(5). + printf '# Managed by lss (module mail-relay).\npostmaster root\nabuse root\n' + [[ -z ${forward} ]] || printf 'root %s\n' "${forward}" } | file_write 0644 root:root "${RELAY_ALIASES}" if [[ -n $(opt user) ]]; then password=$(opt password) @@ -206,8 +207,8 @@ mod_verify() { } mod_remove() { - local unit=postfix.service - os_is_debian && unit=postfix@.service + local unit + unit=$(mail_postfix_template) svc_disable "$(mail_postfix_unit)" svc_disable postfix svc_dropin_remove "${unit}" lss-sandbox diff --git a/modules/mail/mailserver.sh b/modules/mail/mailserver.sh index ac2d2a1..4b26b71 100644 --- a/modules/mail/mailserver.sh +++ b/modules/mail/mailserver.sh @@ -512,7 +512,8 @@ _ms_tables() { file_write 0644 root:root "${MS_RECIPIENT_ACCESS}" { printf '# Managed by lss (module mailserver). Mail for root and the system accounts.\n' - printf 'postmaster: root\nabuse: root\nroot: postmaster@%s\n' "${MS_DOMAINS[0]}" + # texthash reads "key value" lines, not the "key: value" lines of aliases(5). + printf 'postmaster root\nabuse root\nroot postmaster@%s\n' "${MS_DOMAINS[0]}" } | file_write 0644 root:root "${MS_ALIASES}" return } @@ -564,14 +565,14 @@ smtpd_sasl_tls_security_options = noanonymous smtpd_tls_chain_files = ${cert}/privkey.pem, ${cert}/fullchain.pem smtpd_tls_security_level = may smtpd_tls_auth_only = yes -smtpd_tls_protocols = >=TLSv1.2 -smtpd_tls_mandatory_protocols = >=TLSv1.2 +smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 +smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 smtpd_tls_mandatory_ciphers = high smtpd_tls_received_header = yes smtpd_tls_loglevel = 1 smtp_tls_security_level = ${level} -smtp_tls_protocols = >=TLSv1.2 -smtp_tls_mandatory_protocols = >=TLSv1.2 +smtp_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 +smtp_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 smtp_tls_CAfile = $(mail_ca_bundle) smtp_tls_loglevel = 1 # Every message passes Rspamd: spam filter, DKIM signing, SPF, DKIM and DMARC checks. While Rspamd is down, mail waits. @@ -790,11 +791,12 @@ mod_verify() { sleep 2 done [[ ${result} == *'Action: reject'* && ${result} == *GTUBE* ]] || die "mailserver: Rspamd does not reject the GTUBE test message." - # The DNS records name the DKIM key Rspamd signs with. + # The DNS records name the DKIM key Rspamd signs with (its TXT strings joined, as resolvers do). for domain in "${MS_DOMAINS[@]}"; do key="${MS_DKIM_DIR}/${domain}.$(opt dkim_selector).key" [[ -s ${key} ]] || die "mailserver: the DKIM key ${key} is missing." - grep -qF "p=$(openssl pkey -in "${key}" -pubout -outform DER 2>/dev/null | base64 -w 0)" "${MS_DNS_FILE}" || + sed 's/" "//g' "${MS_DNS_FILE}" | + grep -qF "p=$(openssl pkey -in "${key}" -pubout -outform DER 2>/dev/null | base64 -w 0)" || die "mailserver: ${MS_DNS_FILE} does not carry the DKIM key of ${domain}." done # A message through the whole server: sendmail, Postfix, Rspamd (signs it), LMTP into the verify mailbox. @@ -817,8 +819,8 @@ mod_verify() { } mod_remove() { - local unit=postfix.service - os_is_debian && unit=postfix@.service + local unit + unit=$(mail_postfix_template) svc_disable rspamd svc_disable dovecot svc_disable "$(mail_postfix_unit)" diff --git a/tests/unit/mail.bats b/tests/unit/mail.bats index ed847a7..da52ddd 100644 --- a/tests/unit/mail.bats +++ b/tests/unit/mail.bats @@ -56,6 +56,15 @@ load_module() { @test "the Postfix unit is the instance postfix@- on Debian and postfix on the RHEL family" { [ "$(mail_postfix_unit)" = 'postfix@-' ] + [ "$(mail_postfix_template)" = 'postfix@.service' ] + # Postfix 3.10 on Debian 13 and Ubuntu 26.04: postfix.service runs the master itself. + printf '[Service]\nExecStart=/usr/sbin/postfix start\n' >"${BATS_TEST_TMPDIR}/postfix.service" + systemctl() { printf '%s\n' "${BATS_TEST_TMPDIR}/postfix.service"; } + [ "$(mail_postfix_unit)" = postfix ] + [ "$(mail_postfix_template)" = postfix.service ] + printf '[Service]\nExecStart=/bin/true\n' >"${BATS_TEST_TMPDIR}/postfix.service" + [ "$(mail_postfix_unit)" = 'postfix@-' ] + unset -f systemctl use_os rocky-9 [ "$(mail_postfix_unit)" = postfix ] [ "$(mail_ca_bundle)" = /etc/pki/tls/certs/ca-bundle.crt ] @@ -123,7 +132,7 @@ load_module() { [ "$(secret_get mail-relay-password)" = s3cret ] grep -qxF "== 0600 root:root /etc/postfix/lss-sasl-passwd" "${WRITES}" grep -qxF '[smtp.example.com]:25 relay@example.com:s3cret' "${WRITES}" - grep -qxF 'root: admin@example.com' "${WRITES}" + grep -qxF 'root admin@example.com' "${WRITES}" } # --- mailserver -------------------------------------------------------------------------------------------------- @@ -194,7 +203,7 @@ load_module() { grep -qxF 'team@example.com alice@example.com,bob@example.com,someone@example.net' "${WRITES}" grep -qxF 'alice@example.com alice@example.com' "${WRITES}" grep -qxF 'lss-verify@example.com REJECT' "${WRITES}" - grep -qxF 'root: postmaster@example.com' "${WRITES}" + grep -qxF 'root postmaster@example.com' "${WRITES}" run grep -c '^team@example.com ' "${WRITES}" [ "${output}" = 2 ] } From f3b7d441a7724c54545bc30813899cd8f261c5f1 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 09:56:53 +0200 Subject: [PATCH 04/11] fix(mailserver): Dovecot 2.4 names the listener address "listen" doveconf of Dovecot 2.4 (Debian 13, Ubuntu 26.04) refused the plain IMAP listener for webmail: the setting "address" of 2.3 is "listen" in 2.4. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/mail/mailserver.sh | 6 ++++-- tests/unit/mail.bats | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/mail/mailserver.sh b/modules/mail/mailserver.sh index 4b26b71..9d5f556 100644 --- a/modules/mail/mailserver.sh +++ b/modules/mail/mailserver.sh @@ -249,8 +249,10 @@ _ms_users_file() { # _ms_dovecot_conf CERTDIR: the whole dovecot.conf (the distribution's conf.d is not included). _ms_dovecot_conf() { - local cert=$1 listen='*' + local cert=$1 listen='*' address=address [[ $(mail_inet_protocols) == ipv4 ]] || listen='*, ::' + # Dovecot 2.4 names the address of a listener "listen". + ! _ms_dovecot_24 || address=listen printf '# Managed by lss (module mailserver). Change LSS_MAILSERVER_* in /etc/lss/lss.conf and run lss configure mailserver.\n' if _ms_dovecot_24; then cat < Date: Thu, 15 Oct 2026 11:40:32 +0200 Subject: [PATCH 05/11] fix(mail): Roundcube SQLite setup, Dovecot socket directory, EL test curl - Roundcube's SQLite driver creates the schema on the first connection to a new database file, so initdb.sh then failed on the existing tables. updatedb.sh now creates and updates the database alike. - Postfix 3.10 (Debian 13, Ubuntu 26.04) creates /var/spool/postfix/private only on its first start, after Dovecot; the Dovecot sandbox cannot bind a missing directory, so lss creates it with Postfix's owner and mode. - curl-minimal of the RHEL family speaks no SMTP or IMAP; the mail flow test swaps in the full curl there. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/mail/mailserver.sh | 3 +++ modules/mail/webmail.sh | 12 ++++++------ tests/integration/mail-flow.sh | 9 +++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/modules/mail/mailserver.sh b/modules/mail/mailserver.sh index 9d5f556..18ae232 100644 --- a/modules/mail/mailserver.sh +++ b/modules/mail/mailserver.sh @@ -371,6 +371,9 @@ _ms_dovecot() { printf '%s\n' "${output}" >&2 die "mailserver: Dovecot rejects ${MS_DOVECOT_CONF}." } + # Dovecot puts its sockets for Postfix there; Postfix 3.10 creates the directory only on its first start, which + # comes later, and the sandbox needs it to exist. Postfix's own owner and mode. + [[ -d /var/spool/postfix/private ]] || install -d -m 0700 -o postfix -g root /var/spool/postfix/private svc_sandbox dovecot.service "ReadWritePaths=${MS_VMAIL}" ReadWritePaths=-/var/lib/dovecot \ ReadWritePaths=/var/spool/postfix/private svc_enable dovecot diff --git a/modules/mail/webmail.sh b/modules/mail/webmail.sh index 79138a1..c0c91e0 100644 --- a/modules/mail/webmail.sh +++ b/modules/mail/webmail.sh @@ -145,13 +145,13 @@ _rc_database() { if command -v restorecon >/dev/null 2>&1; then restorecon -R "${RC_DATA}" 2>/dev/null || true fi - if [[ ! -s ${RC_DB} ]]; then - log_command _rc_run "${RC_DIR}/bin/initdb.sh" --dir="${RC_DIR}/SQL" || die "webmail: Roundcube could not create its database." - files_note_change "created the Roundcube database" - elif [[ $(state_get webmail schema) != "${RC_VERSION}" ]]; then + # Roundcube's SQLite driver creates the schema itself on the first connection to a new file, so updatedb.sh both + # creates the database and brings an existing one to the schema of this release. + if [[ ! -s ${RC_DB} || $(state_get webmail schema) != "${RC_VERSION}" ]]; then log_command _rc_run "${RC_DIR}/bin/updatedb.sh" --package=roundcube --dir="${RC_DIR}/SQL" || - die "webmail: Roundcube could not update its database to ${RC_VERSION}." - files_note_change "updated the Roundcube database" + die "webmail: Roundcube could not set up its database for ${RC_VERSION}." + [[ -s ${RC_DB} ]] || die "webmail: Roundcube did not create its database ${RC_DB}." + files_note_change "set up the Roundcube database" fi state_set webmail schema "${RC_VERSION}" return diff --git a/tests/integration/mail-flow.sh b/tests/integration/mail-flow.sh index 947b99e..2a344d5 100755 --- a/tests/integration/mail-flow.sh +++ b/tests/integration/mail-flow.sh @@ -121,9 +121,18 @@ fake_dns() { fail "Rspamd does not answer after the restart." } +# full_curl: the RHEL family installs curl-minimal, which speaks no SMTP and IMAP; the test swaps in the full curl. +full_curl() { + [[ $(curl -V) != *' smtp '* ]] || return 0 + dnf -y -q install --allowerasing curl libcurl >/dev/null || fail "the full curl does not install." + [[ $(curl -V) == *' smtp '* ]] || fail "curl still speaks no SMTP." + return +} + server() { local alice bob domain selector token mail result output ip i symbol local -a mailboxes=() + full_curl HOSTNAME_MAIL=$(state mailserver hostname) domain=$(state mailserver domain) selector=$(sed -nE 's/^([a-z0-9-]+)\._domainkey\..*/\1/p' "${DNS_FILE}" | head -n 1) From 757cf048697caaed6a27e26636f4a3d328a08d02 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 11:52:18 +0200 Subject: [PATCH 06/11] test(mail): unit tests without openssl, shellcheck clean The unit test image has no openssl, so the Dovecot user file test now uses a stand-in for "openssl passwd"; the systemctl stub carries the SC2329 note the lint job asks for. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- tests/unit/mail.bats | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/mail.bats b/tests/unit/mail.bats index 9450385..da77a1c 100644 --- a/tests/unit/mail.bats +++ b/tests/unit/mail.bats @@ -59,6 +59,7 @@ load_module() { [ "$(mail_postfix_template)" = 'postfix@.service' ] # Postfix 3.10 on Debian 13 and Ubuntu 26.04: postfix.service runs the master itself. printf '[Service]\nExecStart=/usr/sbin/postfix start\n' >"${BATS_TEST_TMPDIR}/postfix.service" + # shellcheck disable=SC2329 # called by mail_postfix_unit systemctl() { printf '%s\n' "${BATS_TEST_TMPDIR}/postfix.service"; } [ "$(mail_postfix_unit)" = postfix ] [ "$(mail_postfix_template)" = postfix.service ] @@ -252,6 +253,14 @@ load_module() { config_set LSS_MAILSERVER_DOMAINS example.com config_set LSS_MAILSERVER_USERS alice _ms_parse + # A stand-in for "openssl passwd -6 [-salt SALT] -stdin" (the test image has no openssl): salt and digest. + # shellcheck disable=SC2329 # called by _ms_hash + openssl() { + local salt="${RANDOM}${RANDOM}" password + [[ $3 != -salt ]] || salt=$4 + read -r password + printf '$6$%s$%s\n' "${salt}" "$(printf '%s:%s' "${salt}" "${password}" | sha256sum | cut -c 1-43)" + } MS_USERS=${BATS_TEST_TMPDIR}/lss-users _ms_users_file >"${MS_USERS}" grep -qE '^alice@example.com:\{SHA512-CRYPT\}\$6\$[^:]+:990:990::/var/vmail/example.com/alice::$' "${MS_USERS}" From 9ad5701ed6007e537626888874db9c573c8e966c Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 13:41:40 +0200 Subject: [PATCH 07/11] fix(mail): profile all installs the mail server after dns-resolver mailserver sends with DANE when dns-resolver is installed. In profile all it came before dns-resolver, so the second install switched the TLS level and broke idempotency. The README says that lss configure picks up a resolver added later. Unit tests now cover the rest of lib/mail.sh (Postfix install, sandbox, apply, the self-signed certificate and the delivery test), which the coverage gate of lib/ needs. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- README.md | 3 +- profiles/all.conf | 2 +- tests/unit/mail.bats | 117 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e480e75..30c82c1 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,8 @@ resolvers, over TLS with `LSS_DNS_RESOLVER_FORWARD_TLS=true`. `mail-relay` is for servers that only send: mail from root, cron, the guard and applications goes out through `LSS_MAIL_RELAY_RELAYHOST` (STARTTLS on 587 or TLS on 465, the certificate checked, `LSS_MAIL_RELAY_USER` and the password in the lss secrets) or, without a smarthost, straight to the recipients' servers with opportunistic TLS -(DANE when `dns-resolver` is installed). Postfix listens on 127.0.0.1 and ::1 only. Mail for root stays in +(DANE when `dns-resolver` is installed; `mailserver` does the same, and `lss configure` picks up a +resolver added later). Postfix listens on 127.0.0.1 and ::1 only. Mail for root stays in `/var/mail/root` unless `LSS_MAIL_RELAY_FORWARD` names an address. Most smarthosts accept only their own sender addresses; set `LSS_MAIL_RELAY_FROM`. diff --git a/profiles/all.conf b/profiles/all.conf index f362237..249fae7 100644 --- a/profiles/all.conf +++ b/profiles/all.conf @@ -7,4 +7,4 @@ # For the nightly test and as a reference. ssh-2fa, clamav and usbguard stay opt-in as in minimal-secure. k3s # conflicts with docker (both bring their own containerd and claim the forwarding rules) and has its own profile, # k8s-node; podman is the alternative to docker. -LSS_MODULES=system time swap admin-user tools logging ssh-hardening firewall fail2ban kernel-hardening mac auditd auto-updates password-policy aide rkhunter lynis guard nginx tls vhost haproxy modsecurity php composer node python ruby java go dotnet mariadb postgresql redis mongodb phpmyadmin adminer pgadmin docker portainer mailserver webmail restic db-dumps wireguard dns-resolver ntp-server node-exporter prometheus alertmanager grafana loki alloy netdata smartd +LSS_MODULES=system time swap admin-user tools logging ssh-hardening firewall fail2ban kernel-hardening mac auditd auto-updates password-policy aide rkhunter lynis guard nginx tls vhost haproxy modsecurity php composer node python ruby java go dotnet mariadb postgresql redis mongodb phpmyadmin adminer pgadmin docker portainer restic db-dumps wireguard dns-resolver ntp-server mailserver webmail node-exporter prometheus alertmanager grafana loki alloy netdata smartd diff --git a/tests/unit/mail.bats b/tests/unit/mail.bats index da77a1c..076e3a1 100644 --- a/tests/unit/mail.bats +++ b/tests/unit/mail.bats @@ -47,6 +47,123 @@ load_module() { run ! mail_valid_domain 'example.com;reboot' } +@test "Postfix installs without the questions of the Debian package" { + # shellcheck disable=SC2329 # called by mail_postfix_install + pkg_is_installed() { return 1; } + # shellcheck disable=SC2329 # called by mail_postfix_install + pkg_install() { printf 'install %s\n' "$*" >>"${STUB_LOG}"; } + # shellcheck disable=SC2329 # called by mail_postfix_install + debconf-set-selections() { cat >>"${STUB_LOG}"; } + mail_postfix_install + grep -qxF 'postfix postfix/main_mailer_type select No configuration' "${STUB_LOG}" + grep -qxF 'install postfix' "${STUB_LOG}" + : >"${STUB_LOG}" + use_os rocky-9 + mail_postfix_install + [ "$(cat "${STUB_LOG}")" = 'install postfix' ] + [[ $(mail_inet_protocols) == @(all|ipv4) ]] +} + +@test "the Postfix sandbox keeps /dev and opens only the queue, the state and what the module adds" { + # shellcheck disable=SC2329 # called by mail_postfix_sandbox + svc_sandbox() { printf '%s\n' "$@"; } + run mail_postfix_sandbox ReadWritePaths=/var/mail + [ "${lines[0]}" = 'postfix@.service' ] + [[ ${output} == *$'PrivateDevices=no\nReadWritePaths=/var/spool/postfix\nReadWritePaths=/var/lib/postfix\nReadWritePaths=/var/mail'* ]] +} + +@test "Postfix starts once, restarts only after a change and stops the run on a configuration it rejects" { + export LSS_MODULE=mail-relay SVC_START_TIMEOUT=5 + local changes=0 + # shellcheck disable=SC2329 # called by mail_postfix_apply + postfix() { printf 'postfix %s\n' "$*" >>"${STUB_LOG}"; } + # shellcheck disable=SC2329 # called by mail_postfix_apply + files_changes_of() { printf '%s' "${changes}"; } + # shellcheck disable=SC2329 # called by mail_postfix_apply + svc_restart() { printf 'restart %s\n' "$1" >>"${STUB_LOG}"; } + mail_postfix_apply 'myhostname = a.example' 'smtp inet n - n - - smtpd' 0 + grep -qxF '== 0644 root:root /etc/postfix/main.cf' "${WRITES}" + grep -qxF 'postfix check' "${STUB_LOG}" + grep -qxF 'systemctl start postfix@-' "${STUB_LOG}" + run ! grep -q '^restart' "${STUB_LOG}" + : >"${STUB_LOG}" + export STUB_ACTIVE_UNITS=postfix@- + changes=2 + mail_postfix_apply 'myhostname = b.example' 'smtp inet n - n - - smtpd' 1 + run ! grep -qxF 'systemctl start postfix@-' "${STUB_LOG}" + grep -qxF 'restart postfix@-' "${STUB_LOG}" + # shellcheck disable=SC2329 # called by mail_postfix_apply + postfix() { + printf 'postfix: fatal: bad main.cf\n' >&2 + return 1 + } + run mail_postfix_apply 'bad' 'bad' 0 + [ "${status}" -eq 1 ] + [[ ${output} == *'bad main.cf'* && ${output} == *'postfix check rejects the configuration'* ]] +} + +@test "the self-signed mail certificate is made once and again when it names another host" { + export LSS_MODULE=mailserver + MAIL_TLS_DIR=${BATS_TEST_TMPDIR}/tls + # A stand-in for openssl (the test image has none): req writes both files, x509 -checkend passes. + # shellcheck disable=SC2329 # called by mail_tls_cert + openssl() { + printf 'openssl %s\n' "$1" >>"${STUB_LOG}" + [[ $1 == req ]] || return 0 + local arg previous='' + for arg in "$@"; do + [[ ${previous} != -keyout && ${previous} != -out ]] || printf 'PEM\n' >"${arg}" + previous=${arg} + done + } + # shellcheck disable=SC2329 # called by mail_tls_cert + install() { + local -a args=() + while (($#)); do + case $1 in -o | -g | -m) shift 2 ;; *) args+=("$1") && shift ;; esac + done + command install "${args[@]}" + } + # shellcheck disable=SC2329 # called by mail_tls_cert + web_cert_domains() { printf '%s' "${CERT_NAME}"; } + mail_tls_cert mail.example.com + [ -s "${MAIL_TLS_DIR}/privkey.pem" ] && [ -s "${MAIL_TLS_DIR}/fullchain.pem" ] + [ "$(grep -c 'openssl req' "${STUB_LOG}")" -eq 1 ] + CERT_NAME=mail.example.com mail_tls_cert mail.example.com + [ "$(grep -c 'openssl req' "${STUB_LOG}")" -eq 1 ] + CERT_NAME=old.example.com mail_tls_cert mail.example.com + [ "$(grep -c 'openssl req' "${STUB_LOG}")" -eq 2 ] +} + +@test "the delivery test waits until the message left the queue and fails when it stays" { + export LSS_MODULE=mail-relay + # shellcheck disable=SC2329 # called by mail_send_check + sendmail() { cat >"${BATS_TEST_TMPDIR}/sent"; } + # shellcheck disable=SC2329 # called by mail_send_check + sleep() { :; } + printf '0\n' >"${BATS_TEST_TMPDIR}/polls" + # shellcheck disable=SC2329 # called by mail_queue_has + postqueue() { + local polls + polls=$(($(<"${BATS_TEST_TMPDIR}/polls") + 1)) + printf '%s\n' "${polls}" >"${BATS_TEST_TMPDIR}/polls" + ((polls > ${QUEUE_POLLS:-2})) || printf '{"recipients":[{"address":"lss-verify@a.example"}]}\n' + } + run mail_send_check root@a.example lss-verify@a.example 10 + [ "${status}" -eq 0 ] + [[ ${output} =~ ^\$ ]] + grep -qxF 'To: lss-verify@a.example' "${BATS_TEST_TMPDIR}/sent" + [ "$(<"${BATS_TEST_TMPDIR}/polls")" -eq 3 ] + printf '0\n' >"${BATS_TEST_TMPDIR}/polls" + QUEUE_POLLS=100 run mail_send_check root@a.example lss-verify@a.example 3 + [ "${status}" -eq 1 ] + [[ ${output} == *'still in the Postfix queue after 3 s'* ]] + # shellcheck disable=SC2329 # called by mail_send_check + sendmail() { return 75; } + run mail_send_check root@a.example lss-verify@a.example 3 + [[ ${output} == *'sendmail does not accept a message'* ]] +} + @test "master.cf runs nothing chrooted and carries every service Postfix needs" { run mail_master_cf [[ ${output} == *pickup*cleanup*qmgr*smtp*local*virtual*lmtp*postlogd* ]] From bd0229d6239e8057b546a4ea7fe52e78695b0b3f Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 13:54:20 +0200 Subject: [PATCH 08/11] refactor(mail): SonarQube Cloud findings on the mail modules Positional parameters go into named locals, the header line of the mailserver tables is one constant, and the message size limit uses a shift, so the analyzer no longer reads the multiplication as a glob. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/mail.sh | 3 ++- modules/mail/mail-relay.sh | 3 ++- modules/mail/mailserver.sh | 16 +++++++++------- tests/integration/mail-flow.sh | 6 ++++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/mail.sh b/lib/mail.sh index 9c9ed2e..5b84cec 100644 --- a/lib/mail.sh +++ b/lib/mail.sh @@ -34,7 +34,8 @@ mail_fqdn() { # mail_valid_domain NAME: a DNS name with at least two labels (example.com, mail.example.org). mail_valid_domain() { - [[ $1 =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$ ]] + local name=$1 + [[ ${name} =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$ ]] return } diff --git a/modules/mail/mail-relay.sh b/modules/mail/mail-relay.sh index 80df5ff..7fc1dbb 100644 --- a/modules/mail/mail-relay.sh +++ b/modules/mail/mail-relay.sh @@ -22,7 +22,8 @@ RELAY_SASL=/etc/postfix/lss-sasl-passwd RELAY_GENERIC=/etc/postfix/lss-generic _relay_address_ok() { - [[ $1 =~ ^[A-Za-z0-9._%+-]+@([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$ ]] + local address=$1 + [[ ${address} =~ ^[A-Za-z0-9._%+-]+@([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$ ]] return } diff --git a/modules/mail/mailserver.sh b/modules/mail/mailserver.sh index 18ae232..86cd836 100644 --- a/modules/mail/mailserver.sh +++ b/modules/mail/mailserver.sh @@ -28,6 +28,8 @@ MS_MAILBOXES=/etc/postfix/lss-mailboxes MS_VIRTUAL=/etc/postfix/lss-virtual MS_SENDERS=/etc/postfix/lss-senders MS_ALIASES=/etc/postfix/lss-aliases +# The first line of the files lss writes for this module. +MS_HEADER='# Managed by lss (module mailserver).' MS_RECIPIENT_ACCESS=/etc/postfix/lss-recipient-access MS_RSPAMD=/etc/rspamd/local.d MS_DKIM_DIR=/var/lib/rspamd/dkim @@ -484,20 +486,20 @@ _ms_tables() { local -a owners=() verify="${MAIL_VERIFY_LOCALPART}@${MS_DOMAINS[0]}" { - printf '# Managed by lss (module mailserver).\n' + printf '%s\n' "${MS_HEADER}" for address in "${MS_ADDRESSES[@]}" "${verify}"; do printf '%s OK\n' "${address}" done } | file_write 0644 root:root "${MS_MAILBOXES}" { - printf '# Managed by lss (module mailserver).\n' + printf '%s\n' "${MS_HEADER}" for source in $(printf '%s\n' "${!MS_ALIAS[@]}" | sort); do printf '%s %s\n' "${source}" "${MS_ALIAS[${source}]}" done } | file_write 0644 root:root "${MS_VIRTUAL}" # A login sends as its own address and as the aliases that lead to it. { - printf '# Managed by lss (module mailserver).\n' + printf '%s\n' "${MS_HEADER}" for address in "${MS_ADDRESSES[@]}"; do printf '%s %s\n' "${address}" "${address}" done @@ -539,7 +541,7 @@ mynetworks = 127.0.0.0/8 [::1]/128 smtpd_banner = \$myhostname ESMTP biff = no append_dot_mydomain = no -message_size_limit = $(($(opt_int message_size) * 1024 * 1024)) +message_size_limit = $(($(opt_int message_size) << 20)) mailbox_size_limit = 0 recipient_delimiter = + virtual_mailbox_domains = ${MS_DOMAINS[*]} @@ -606,7 +608,7 @@ _ms_submission() { } _ms_master() { - printf '# Managed by lss (module mailserver).\n' + printf '%s\n' "${MS_HEADER}" printf 'smtp inet n - n - - smtpd\n' _ms_submission submission submission smtpd_tls_security_level=encrypt smtpd_tls_auth_only=yes _ms_submission submissions 465 smtpd_tls_wrappermode=yes smtpd_tls_security_level=encrypt smtpd_tls_auth_only=yes @@ -620,8 +622,8 @@ _ms_master() { # _ms_ptr4 ADDRESS / _ms_ptr6 ADDRESS: the reverse DNS name of an IPv4 or IPv6 address. _ms_ptr4() { - local a b c d - IFS=. read -r a b c d <<<"$1" + local address=$1 a b c d + IFS=. read -r a b c d <<<"${address}" printf '%s.%s.%s.%s.in-addr.arpa.' "${d}" "${c}" "${b}" "${a}" return } diff --git a/tests/integration/mail-flow.sh b/tests/integration/mail-flow.sh index 2a344d5..e763992 100755 --- a/tests/integration/mail-flow.sh +++ b/tests/integration/mail-flow.sh @@ -33,7 +33,8 @@ fail() { } state() { - sed -n "s/^$2=//p" "${STATE}/$1" + local module=$1 key=$2 + sed -n "s/^${key}=//p" "${STATE}/${module}" return } @@ -91,7 +92,8 @@ fetch_subject() { # txt NAME: the value of a TXT record in the DNS records lss printed, its strings joined. txt() { - awk -v name="$1." '$1 == name && $2 == "TXT" { $1 = ""; $2 = ""; print }' "${DNS_FILE}" | + local name=$1 + awk -v name="${name}." '$1 == name && $2 == "TXT" { $1 = ""; $2 = ""; print }' "${DNS_FILE}" | sed -E 's/^ +//; s/" "//g; s/^"//; s/"$//' return } From 451f783fb340fcd25a19b1eab74c6e025c863914 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 15:52:09 +0200 Subject: [PATCH 09/11] docs: resource figures of the mail modules, measured in CI The figures come from the RESOURCES lines of Integration run 38730695070 on all seven systems, x86_64 and arm64. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 30c82c1..0213b3a 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,12 @@ up). Peak RAM covers install and `lss verify`; "while running" is what stays res | `wireguard` | 15 s | 260 MiB | 100 MiB | – (kernel) | | `dns-resolver` | 45 s | 180 MiB | 40 MiB | Unbound about 20 MiB | | `ntp-server` | 15 s | 195 MiB | 10 MiB | – (the chronyd of `time`, about 10 MiB) | +| `mail-relay` | 35 s | 250 MiB | 180 MiB (dnf), else 135 MiB | Postfix (master, queue manager, pickup) | +| `mailserver` | 90 s | 800 MiB (dnf), else 225 MiB | 235 MiB (dnf), else 120 MiB | Postfix, Dovecot and Rspamd, up to 180 MiB | +| `webmail` | 135 s with nginx, php and mailserver | 390 MiB together | 265 MiB together | Roundcube in PHP-FPM; with nginx and the mail server up to 290 MiB | Modules the tests install together are measured together. The Rocky 9 VM in CI is slow throughout (eight minutes even -for `wireguard`); those outliers are left out. +for `wireguard`); those outliers are left out, like one Rocky 9 container run of `mail-relay` that took six minutes. The integration jobs print these figures as `RESOURCES` lines, so every new module and every new system adds its own. From 486758d552d05756fb6a4b3afae179438e56b7dd Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 22:19:23 +0200 Subject: [PATCH 10/11] fix(mailserver): tie the From header to the login and sign only its domain A security review found that a logged-in user could put any hosted address into the From header and get that domain's DKIM signature: reject_sender_login_mismatch of Postfix checks only the envelope sender, and dkim_signing had allow_username_mismatch = true. - dkim_signing: allow_username_mismatch = false, so mail of a login is signed only with the key of the login's domain (use_esld stays). - An Rspamd prefilter (lua.local.d/lss-sender.lua) rejects mail of a logged-in sender unless its single From address is one the login owns, from a copy of the Postfix sender map below /etc/rspamd that Rspamd can read under SELinux and AppArmor. Aliases leading to the login stay allowed. - sign_local stays for mail of the server itself; a From of a domain the server does not host has no key and stays unsigned. The comment explains why local senders are trusted like the server. - lss verify checks the prefilter both ways; the mail flow test sends with the From of another mailbox and of an unowned address in another hosted domain (rejected), as an owned alias (signed), and local mail with a hosted and an unhosted From (signed, unsigned). Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- README.md | 11 ++- docs/hardening.md | 2 +- modules/mail/mailserver.sh | 137 +++++++++++++++++++++++++++------ tests/integration/mail-flow.sh | 37 ++++++++- tests/unit/mail.bats | 44 +++++++++++ 5 files changed, 202 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 4a2d7eb..9c3ca81 100644 --- a/README.md +++ b/README.md @@ -367,10 +367,13 @@ LSS_MAIL_RELAY_FORWARD=admin@example.com ``` `mailserver` receives and delivers mail for `LSS_MAILSERVER_DOMAINS`: Postfix on port 25, submission on 587 -(STARTTLS) and 465 (TLS) with login only and the sender tied to the login, Dovecot with IMAPS on 993, Rspamd as spam -filter (reject from score 15, a header from 6) that signs outgoing mail with DKIM and checks SPF, DKIM and DMARC of -incoming mail. Mailboxes are `LSS_MAILSERVER_USERS`, aliases `LSS_MAILSERVER_ALIASES`; postmaster and abuse of every -domain go to the first mailbox. Each password is a secret: +(STARTTLS) and 465 (TLS) with login only, Dovecot with IMAPS on 993, Rspamd as spam filter (reject from score 15, a +header from 6) that signs outgoing mail with DKIM and checks SPF, DKIM and DMARC of incoming mail. Mailboxes are +`LSS_MAILSERVER_USERS`, aliases `LSS_MAILSERVER_ALIASES`; postmaster and abuse of every domain go to the first mailbox. +A login sends only as its own address and the aliases that lead to it, in the envelope (Postfix) and in the From +header (Rspamd); anything else is rejected. DKIM signs mail of a login with the key of the login's domain only, so an +alias in another domain goes out unsigned. Mail the server sends itself (sendmail, cron, the guard) is signed when its +From names a hosted domain. Each password is a secret: ```bash LSS_MAILSERVER_DOMAINS=example.com example.org diff --git a/docs/hardening.md b/docs/hardening.md index bde6c45..e7c61e2 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -54,7 +54,7 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D | `guard` with a container engine | Trivy scans every image daily and reports HIGH and CRITICAL vulnerabilities per image | CIS Docker, section 4 (images) | | `haproxy` | Statistics only on 127.0.0.1, admin socket readable by root only, chroot, systemd sandbox, ports in the firewall only for services that are not `local` | (operations) | | `mail-relay` | Postfix on 127.0.0.1 and ::1 only, no relaying for anyone else; TLS to the smarthost with its certificate checked against the system CAs (DANE or opportunistic TLS without one), the smarthost password in a 0600 table; no delivery to programs, files or `~/.forward`; no chroot, a systemd sandbox instead | (operations) | -| `mailserver` | Postfix: no open relay, submission on 587 and 465 only with TLS 1.2 or later and a login, the sender address tied to the login, login attempts rate-limited, no VRFY, strict HELO and recipient checks; Dovecot: IMAPS only (plain IMAP on 127.0.0.1 for webmail), SHA-512-crypt passwords in a 0640 file, mailboxes in `/var/vmail` (0700, own user); Rspamd: rejects spam, signs with DKIM (RSA 2048, keys 0640), checks SPF, DKIM and DMARC, its web interface off; every service in a systemd sandbox | (operations; Mozilla TLS guidelines) | +| `mailserver` | Postfix: no open relay, submission on 587 and 465 only with TLS 1.2 or later and a login, the sender address tied to the login in the envelope and, through an Rspamd prefilter, in the From header, login attempts rate-limited, no VRFY, strict HELO and recipient checks; Dovecot: IMAPS only (plain IMAP on 127.0.0.1 for webmail), SHA-512-crypt passwords in a 0640 file, mailboxes in `/var/vmail` (0700, own user); Rspamd: rejects spam, signs with DKIM (RSA 2048, keys 0640) only for the domain of the login or for mail of the server itself, checks SPF, DKIM and DMARC, its web interface off; every service in a systemd sandbox | (operations; Mozilla TLS guidelines) | | `webmail` | Checksum-verified Roundcube without the installer, configuration readable by root and PHP-FPM only, a generated DES key, IMAP and SMTP on 127.0.0.1 only, strict SameSite session cookies bound to the client address, HTTPS only through the web server | OWASP Session Management | ## Deliberate deviations diff --git a/modules/mail/mailserver.sh b/modules/mail/mailserver.sh index 86cd836..5f76780 100644 --- a/modules/mail/mailserver.sh +++ b/modules/mail/mailserver.sh @@ -32,6 +32,10 @@ MS_ALIASES=/etc/postfix/lss-aliases MS_HEADER='# Managed by lss (module mailserver).' MS_RECIPIENT_ACCESS=/etc/postfix/lss-recipient-access MS_RSPAMD=/etc/rspamd/local.d +# The header-From check of Rspamd: its Lua rule and its copy of the sender map, below /etc/rspamd so that Rspamd may +# read them under SELinux and AppArmor. +MS_RSPAMD_LUA=/etc/rspamd/lua.local.d/lss-sender.lua +MS_RSPAMD_SENDERS=/etc/rspamd/lss-senders.map MS_DKIM_DIR=/var/lib/rspamd/dkim MS_RSPAMD_USER=_rspamd MS_DNS_FILE=/etc/lss/mailserver-dns.txt @@ -440,8 +444,14 @@ reject = $(opt_int spam_reject); add_header = $(opt_int spam_header); greylist = null; EOF - # Mail from logged-in users and from this server is signed with the key of its domain (or of the domain above a - # host name: root@mail.example.com signs as example.com). + # Mail from logged-in users and from this server is signed with the key of the domain in its From header (or of the + # domain above a host name: root@mail.example.com signs as example.com), and only when Rspamd has a key for that + # domain. A logged-in user gets a signature only for the domain of the login (allow_username_mismatch = false), and + # the header-From check (_ms_sender_filter) has already refused every From address the login does not own; an + # alias of another domain is delivered, but unsigned. sign_local covers mail that leaves this server itself + # (sendmail, cron, the guard, local SMTP without a login): whoever runs programs here can read and send as the + # server anyway, so it is trusted like the server; a From of a domain this server does not host gets no key and + # no signature. file_write 0644 root:root "${MS_RSPAMD}/dkim_signing.conf" </dev/null 2>&1 || { rspamadm configtest >&2 || true die "mailserver: Rspamd rejects its configuration." @@ -478,12 +489,91 @@ EOF return } +# _ms_sender_filter: Postfix ties only the envelope sender to the login (reject_sender_login_mismatch); this Rspamd +# prefilter does the same for the From header, which recipients see and DKIM signs. A logged-in sender passes only with +# exactly one From address that the login owns (its own address or an alias leading to it, from the sender map). +_ms_sender_filter() { + install -d -m 0755 "${MS_RSPAMD_LUA%/*}" + _ms_senders | file_write 0640 "root:${MS_RSPAMD_USER}" "${MS_RSPAMD_SENDERS}" + file_write 0644 root:root "${MS_RSPAMD_LUA}" <", login, address) + task:set_pre_result("reject", reply, "lss") + return true + end, +}) +EOF + return +} + # --- Postfix --------------------------------------------------------------------------------------------------------- # _ms_tables: the mailboxes, the aliases, which login may send as which address, and the local aliases of root. -_ms_tables() { - local address source target verify +# _ms_senders: the sender map, ADDRESS LOGIN[,LOGIN...] per line. A login sends as its own address and as the aliases +# that lead to it. Postfix checks the envelope sender with it, Rspamd the address in the From header. +_ms_senders() { + local address source target local -a owners=() + printf '%s\n' "${MS_HEADER}" + for address in "${MS_ADDRESSES[@]}"; do + printf '%s %s\n' "${address}" "${address}" + done + for source in $(printf '%s\n' "${!MS_ALIAS[@]}" | sort); do + owners=() + for target in ${MS_ALIAS[${source}]//,/ }; do + [[ " ${MS_ADDRESSES[*]} " != *" ${target} "* ]] || owners+=("${target}") + done + ((${#owners[@]} == 0)) || printf '%s %s\n' "${source}" "$( + IFS=, + printf '%s' "${owners[*]}" + )" + done + return +} + +_ms_tables() { + local address source verify verify="${MAIL_VERIFY_LOCALPART}@${MS_DOMAINS[0]}" { printf '%s\n' "${MS_HEADER}" @@ -497,23 +587,7 @@ _ms_tables() { printf '%s %s\n' "${source}" "${MS_ALIAS[${source}]}" done } | file_write 0644 root:root "${MS_VIRTUAL}" - # A login sends as its own address and as the aliases that lead to it. - { - printf '%s\n' "${MS_HEADER}" - for address in "${MS_ADDRESSES[@]}"; do - printf '%s %s\n' "${address}" "${address}" - done - for source in $(printf '%s\n' "${!MS_ALIAS[@]}" | sort); do - owners=() - for target in ${MS_ALIAS[${source}]//,/ }; do - [[ " ${MS_ADDRESSES[*]} " != *" ${target} "* ]] || owners+=("${target}") - done - ((${#owners[@]} == 0)) || printf '%s %s\n' "${source}" "$( - IFS=, - printf '%s' "${owners[*]}" - )" - done - } | file_write 0644 root:root "${MS_SENDERS}" + _ms_senders | file_write 0644 root:root "${MS_SENDERS}" # Nobody outside reaches the verify mailbox; lss verify delivers to it with sendmail. printf '# Managed by lss (module mailserver).\n%s REJECT\n' "${verify}" | file_write 0644 root:root "${MS_RECIPIENT_ACCESS}" @@ -743,6 +817,13 @@ mod_configure() { # --- Verify ---------------------------------------------------------------------------------------------------------- +# _ms_from_test LOGIN FROM: Rspamd's verdict on a message of LOGIN with FROM in its From header. +_ms_from_test() { + printf 'From: %s\nTo: %s\nSubject: lss sender test\nMessage-ID: \n\nlss verify\n' \ + "$2" "$1" "${MS_DOMAINS[0]}" | rspamc -h "${MS_SCANNER}" --user "$1" symbols 2>/dev/null || true + return +} + # _ms_gtube: the GTUBE test message, which every spam filter rejects. _ms_gtube() { printf 'From: lss@%s\nTo: %s@%s\nSubject: lss spam filter test\nMessage-ID: \n\n' \ @@ -798,6 +879,16 @@ mod_verify() { sleep 2 done [[ ${result} == *'Action: reject'* && ${result} == *GTUBE* ]] || die "mailserver: Rspamd does not reject the GTUBE test message." + # A logged-in sender may not put an address of somebody else into From (here the verify mailbox, which no login + # owns), but passes with its own. + if ((${#MS_ADDRESSES[@]} > 0)); then + result=$(_ms_from_test "${MS_ADDRESSES[0]}" "${MAIL_VERIFY_LOCALPART}@${MS_DOMAINS[0]}") + [[ ${result} == *'Action: reject'* ]] || + die "mailserver: Rspamd lets ${MS_ADDRESSES[0]} send with the From address of another login." + result=$(_ms_from_test "${MS_ADDRESSES[0]}" "${MS_ADDRESSES[0]}") + [[ ${result} == *'Action:'* && ${result} != *'Action: reject'* ]] || + die "mailserver: Rspamd rejects ${MS_ADDRESSES[0]} with its own From address." + fi # The DNS records name the DKIM key Rspamd signs with (its TXT strings joined, as resolvers do). for domain in "${MS_DOMAINS[@]}"; do key="${MS_DKIM_DIR}/${domain}.$(opt dkim_selector).key" @@ -839,7 +930,7 @@ mod_remove() { for file in "${MS_MAILBOXES}" "${MS_VIRTUAL}" "${MS_SENDERS}" "${MS_ALIASES}" "${MS_RECIPIENT_ACCESS}" "${MS_USERS}" \ "${MS_DOVECOT_CONF}" "${MS_DNS_FILE}" "${MS_RSPAMD}/worker-normal.inc" "${MS_RSPAMD}/worker-proxy.inc" \ "${MS_RSPAMD}/worker-controller.inc" "${MS_RSPAMD}/actions.conf" "${MS_RSPAMD}/dkim_signing.conf" \ - "${MS_RSPAMD}/milter_headers.conf" "${MS_RSPAMD}/logging.inc"; do + "${MS_RSPAMD}/milter_headers.conf" "${MS_RSPAMD}/logging.inc" "${MS_RSPAMD_LUA}" "${MS_RSPAMD_SENDERS}"; do file_remove "${file}" done files_forget_path "${MAIL_POSTFIX_MAIN}" diff --git a/tests/integration/mail-flow.sh b/tests/integration/mail-flow.sh index e763992..e03a9dc 100755 --- a/tests/integration/mail-flow.sh +++ b/tests/integration/mail-flow.sh @@ -90,6 +90,13 @@ fetch_subject() { return } +# signed MESSAGE DOMAIN: MESSAGE carries a DKIM signature of DOMAIN (folded headers joined). +signed() { + local mail=$1 domain=$2 + grep -qiE "^DKIM-Signature:.*d=${domain//./\\.};" <<<"$(sed -e ':a' -e 'N;$!ba' -e 's/\n[ \t]\+/ /g' <<<"${mail}")" + return +} + # txt NAME: the value of a TXT record in the DNS records lss printed, its strings joined. txt() { local name=$1 @@ -132,7 +139,7 @@ full_curl() { } server() { - local alice bob domain selector token mail result output ip i symbol + local alice bob domain selector token mail result output ip i symbol forged local -a mailboxes=() full_curl HOSTNAME_MAIL=$(state mailserver hostname) @@ -162,6 +169,34 @@ server() { fetch_subject "${alice}" "lss flow 465 ${token}" >/dev/null say "the reply arrived in the inbox of ${alice}" + # The From header must name an address of the login: another mailbox and an unowned address of another hosted + # domain are rejected even though the envelope sender is the login's own; an alias the login owns passes and is + # signed. + for forged in "${bob}" "ceo@${bob#*@}"; do + message "${forged}" "${alice}" "lss flow forged ${token}" + output=$(smtp "smtp://${HOSTNAME_MAIL}:587" "${alice}" "${alice}" "${alice}" --ssl-reqd) && + fail "${alice} sent with the From address ${forged}." + [[ ${output} == *'(55)'* || ${output} == *554* ]] || fail "the From address ${forged} was not rejected: ${output}" + done + say "${alice} cannot send with the From address ${bob} or ceo@${bob#*@} (rejected by Rspamd)" + message "info@${domain}" "${bob}" "lss flow alias ${token}" + output=$(smtp "smtp://${HOSTNAME_MAIL}:587" "${alice}" "${bob}" "${alice}" --ssl-reqd) || + fail "${alice} could not send as its alias info@${domain}: ${output}" + signed "$(fetch_subject "${bob}" "lss flow alias ${token}")" "${domain}" || + fail "the mail of the alias info@${domain} carries no DKIM signature of ${domain}." + say "${alice} sends as its alias info@${domain}, signed with the key of ${domain}" + + # Mail of this server itself (sendmail): signed when its From names a hosted domain, unsigned for any other domain. + message "root@${HOSTNAME_MAIL}" "${bob}" "lss flow local ${token}" + sendmail -i -f "root@${HOSTNAME_MAIL}" -- "${bob}" <"${WORK}/message" || fail "sendmail refused a local message." + signed "$(fetch_subject "${bob}" "lss flow local ${token}")" "${domain}" || + fail "local mail from root@${HOSTNAME_MAIL} carries no DKIM signature of ${domain}." + message "someone@unhosted.example.net" "${bob}" "lss flow unhosted ${token}" + sendmail -i -f "root@${HOSTNAME_MAIL}" -- "${bob}" <"${WORK}/message" || fail "sendmail refused a local message." + ! grep -qi '^DKIM-Signature:' <<<"$(fetch_subject "${bob}" "lss flow unhosted ${token}")" || + fail "local mail with a From of a domain this server does not host was signed." + say "local mail is signed for root@${HOSTNAME_MAIL} and left unsigned for a domain this server does not host" + # DKIM, SPF and DMARC of the delivered message, checked with the printed records as DNS. fake_dns "${domain}" "${selector}" result=$(rspamc -h 127.0.0.1:11333 --ip "${TEST_IP}" --helo "${HOSTNAME_MAIL}" --from "${alice}" symbols <<<"${mail}") diff --git a/tests/unit/mail.bats b/tests/unit/mail.bats index 076e3a1..d9eb790 100644 --- a/tests/unit/mail.bats +++ b/tests/unit/mail.bats @@ -326,6 +326,50 @@ load_module() { [ "${output}" = 2 ] } +@test "Rspamd checks the From header of logged-in senders against the same sender map as Postfix" { + load_module mailserver + config_set LSS_MAILSERVER_DOMAINS 'example.com example.org' + config_set LSS_MAILSERVER_USERS 'alice bob@example.org' + config_set LSS_MAILSERVER_ALIASES 'info:alice,bob@example.org' + _ms_parse + MS_RSPAMD_LUA=${BATS_TEST_TMPDIR}/lua.local.d/lss-sender.lua + _ms_sender_filter + [ -d "${BATS_TEST_TMPDIR}/lua.local.d" ] + # The Rspamd copy of the map is the Postfix map, readable by Rspamd only. + run sed -n '/^== 0640 root:_rspamd \/etc\/rspamd\/lss-senders.map$/,/^== /p' "${WRITES}" + [[ ${output} == *$'\nalice@example.com alice@example.com\n'* ]] + [[ ${output} == *$'\ninfo@example.com alice@example.com,bob@example.org\n'* ]] + [[ ${output} == *$'\npostmaster@example.org alice@example.com'* ]] + [ "$(sed -n '2,$p' <<<"${output}" | grep -v '^== ')" = "$(_ms_senders)" ] + # The Lua prefilter reads that map, needs exactly one From address the login owns and rejects everything else. + run sed -n "/^== 0644 root:root ${MS_RSPAMD_LUA//\//\\/}$/,\$p" "${WRITES}" + [[ ${output} == *'local map_path = "/etc/rspamd/lss-senders.map"'* ]] + [[ ${output} == *'type = "prefilter"'* ]] + [[ ${output} == *'task:get_user()'* && ${output} == *'#headers == 1 and #from == 1'* ]] + [[ ${output} == *'task:set_pre_result("reject", reply, "lss")'* ]] + [[ ${output} == *'local reply = "5.7.1 From header address belongs to another login"'* ]] + [[ ${output} != *'"Not'* && ${output} != *'"No'* ]] +} + +@test "DKIM signs only the domain of the login, and mail of this server only with a key of a hosted domain" { + load_module mailserver + config_set LSS_MAILSERVER_DOMAINS example.com + _ms_parse + # shellcheck disable=SC2329 # called by _ms_rspamd + rspamadm() { :; } + # shellcheck disable=SC2329 # called by _ms_rspamd + svc_sandbox() { :; } + MS_RSPAMD=${BATS_TEST_TMPDIR}/local.d + MS_RSPAMD_LUA=${BATS_TEST_TMPDIR}/lua.local.d/lss-sender.lua + _ms_rspamd + run sed -n "/dkim_signing.conf$/,/^== /p" "${WRITES}" + [[ ${output} == *$'\nallow_username_mismatch = false;\n'* ]] + [[ ${output} == *$'\nuse_domain = "header";\nuse_esld = true;\n'* ]] + [[ ${output} == *$'\nsign_local = true;\n'* ]] + [[ ${output} == *'path = "/var/lib/rspamd/dkim/$domain.$selector.key";'* ]] + grep -qxF '== 0640 root:_rspamd /etc/rspamd/lss-senders.map' "${WRITES}" +} + @test "main.cf filters every message through Rspamd and allows login only with TLS" { load_module mailserver config_set LSS_MAILSERVER_DOMAINS 'example.com example.org' From eeb61c21745b91fbb7883ade932d5d5c17b9ef6f Mon Sep 17 00:00:00 2001 From: VectoDE Date: Fri, 16 Oct 2026 02:06:33 +0200 Subject: [PATCH 11/11] fix(mailserver): plain reject text for the header-From check With "5.7.1" at the start of the text, the SMTP reply for a rejected From header confused clients (curl: weird server reply). Rspamd adds the status codes itself. The mail flow test now confirms from the Postfix log that the header-From check refused the message. Refs #7 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/mail/mailserver.sh | 2 +- tests/integration/mail-flow.sh | 18 ++++++++++++++++-- tests/unit/mail.bats | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/modules/mail/mailserver.sh b/modules/mail/mailserver.sh index 5f76780..51e8bc7 100644 --- a/modules/mail/mailserver.sh +++ b/modules/mail/mailserver.sh @@ -500,7 +500,7 @@ _ms_sender_filter() { local rspamd_logger = require "rspamd_logger" local map_path = "${MS_RSPAMD_SENDERS}" -local reply = "5.7.1 From header address belongs to another login" +local reply = "From header address belongs to another login" local senders = {} local loaded = false diff --git a/tests/integration/mail-flow.sh b/tests/integration/mail-flow.sh index e03a9dc..2a3a27d 100755 --- a/tests/integration/mail-flow.sh +++ b/tests/integration/mail-flow.sh @@ -90,6 +90,12 @@ fetch_subject() { return } +# rejects: how many messages Postfix refused because Rspamd's header-From check rejected them. +rejects() { + journalctl --no-pager -q -o cat SYSLOG_FACILITY=2 | grep -c 'milter-reject.*belongs to another login' || true + return +} + # signed MESSAGE DOMAIN: MESSAGE carries a DKIM signature of DOMAIN (folded headers joined). signed() { local mail=$1 domain=$2 @@ -139,7 +145,7 @@ full_curl() { } server() { - local alice bob domain selector token mail result output ip i symbol forged + local alice bob domain selector token mail result output ip i symbol forged seen local -a mailboxes=() full_curl HOSTNAME_MAIL=$(state mailserver hostname) @@ -172,11 +178,19 @@ server() { # The From header must name an address of the login: another mailbox and an unowned address of another hosted # domain are rejected even though the envelope sender is the login's own; an alias the login owns passes and is # signed. + seen=$(rejects) for forged in "${bob}" "ceo@${bob#*@}"; do message "${forged}" "${alice}" "lss flow forged ${token}" output=$(smtp "smtp://${HOSTNAME_MAIL}:587" "${alice}" "${alice}" "${alice}" --ssl-reqd) && fail "${alice} sent with the From address ${forged}." - [[ ${output} == *'(55)'* || ${output} == *554* ]] || fail "the From address ${forged} was not rejected: ${output}" + # Postfix logs the reason; curl only reports that the message was refused. + for ((i = 0; i < 10; i++)); do + (($(rejects) > seen)) && break + sleep 1 + done + (($(rejects) > seen)) || + fail "the From address ${forged} was refused for another reason: ${output} $(journalctl --no-pager -q -n 15 SYSLOG_FACILITY=2)" + seen=$(rejects) done say "${alice} cannot send with the From address ${bob} or ceo@${bob#*@} (rejected by Rspamd)" message "info@${domain}" "${bob}" "lss flow alias ${token}" diff --git a/tests/unit/mail.bats b/tests/unit/mail.bats index d9eb790..22cc077 100644 --- a/tests/unit/mail.bats +++ b/tests/unit/mail.bats @@ -347,7 +347,7 @@ load_module() { [[ ${output} == *'type = "prefilter"'* ]] [[ ${output} == *'task:get_user()'* && ${output} == *'#headers == 1 and #from == 1'* ]] [[ ${output} == *'task:set_pre_result("reject", reply, "lss")'* ]] - [[ ${output} == *'local reply = "5.7.1 From header address belongs to another login"'* ]] + [[ ${output} == *'local reply = "From header address belongs to another login"'* ]] [[ ${output} != *'"Not'* && ${output} != *'"No'* ]] }