diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index d2d34d88..7cd3a56b 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -32,6 +32,7 @@ DEFAULT_CONFIG = { 'DOMAIN': 'mailu.io', 'HOSTNAMES': 'mail.mailu.io,alternative.mailu.io,yetanother.mailu.io', 'POSTMASTER': 'postmaster', + 'WILDCARD_SENDERS': '', 'TLS_FLAVOR': 'cert', 'INBOUND_TLS_ENFORCE': False, 'AUTH_RATELIMIT': '1000/minute;10000/hour', @@ -46,6 +47,7 @@ DEFAULT_CONFIG = { 'DKIM_SELECTOR': 'dkim', 'DKIM_PATH': '/dkim/{domain}.{selector}.key', 'DEFAULT_QUOTA': 1000000000, + 'MESSAGE_RATELIMIT': '200/day', # Web settings 'SITENAME': 'Mailu', 'WEBSITE': 'https://mailu.io', diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 3f5582cc..5e60cd0c 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -81,6 +81,13 @@ def handle_authentication(headers): raw_password = urllib.parse.unquote(headers["Auth-Pass"]) password = raw_password.encode("iso8859-1").decode("utf8") ip = urllib.parse.unquote(headers["Client-Ip"]) + service_port = int(urllib.parse.unquote(headers["Auth-Port"])) + if service_port == 25: + return { + "Auth-Status": "AUTH not supported", + "Auth-Error-Code": "502 5.5.1", + "Auth-Wait": 0 + } user = models.User.query.get(user_email) if check_credentials(user, password, ip, protocol): return { diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py index c358c37f..2e7d0b9b 100644 --- a/core/admin/mailu/internal/views/postfix.py +++ b/core/admin/mailu/internal/views/postfix.py @@ -1,5 +1,6 @@ -from mailu import models +from mailu import models, utils from mailu.internal import internal +from flask import current_app as app import flask import idna @@ -31,7 +32,6 @@ def postfix_alias_map(alias): destination = models.Email.resolve_destination(localpart, domain_name) return flask.jsonify(",".join(destination)) if destination else flask.abort(404) - @internal.route("/postfix/transport/") def postfix_transport(email): if email == '*' or re.match("(^|.*@)\[.*\]$", email): @@ -133,12 +133,20 @@ def postfix_sender_map(sender): @internal.route("/postfix/sender/login/") def postfix_sender_login(sender): + wildcard_senders = [s for s in flask.current_app.config.get('WILDCARD_SENDERS', '').lower().replace(' ', '').split(',') if s] localpart, domain_name = models.Email.resolve_domain(sender) if localpart is None: - return flask.abort(404) + return flask.jsonify(",".join(wildcard_senders)) if wildcard_senders else flask.abort(404) destination = models.Email.resolve_destination(localpart, domain_name, True) + destination = [*destination, *wildcard_senders] if destination else [*wildcard_senders] return flask.jsonify(",".join(destination)) if destination else flask.abort(404) +@internal.route("/postfix/sender/rate/") +def postfix_sender_rate(sender): + """ Rate limit outbound emails per sender login + """ + user = models.User.get(sender) or flask.abort(404) + return flask.abort(404) if user.sender_limiter.hit() else flask.jsonify("450 4.2.1 You are sending too many emails too fast.") @internal.route("/postfix/sender/access/") def postfix_sender_access(sender): diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index 3a299786..5760c27f 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -27,7 +27,7 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.inspection import inspect from werkzeug.utils import cached_property -from mailu import dkim +from mailu import dkim, utils db = flask_sqlalchemy.SQLAlchemy() @@ -501,6 +501,12 @@ class User(Base, Email): self.reply_enddate > now ) + @property + def sender_limiter(self): + return utils.limiter.get_limiter( + app.config["MESSAGE_RATELIMIT"], "sender", self.email + ) + @classmethod def get_password_context(cls): """ create password context for hashing and verification diff --git a/core/admin/mailu/ui/templates/user/list.html b/core/admin/mailu/ui/templates/user/list.html index 2aff662f..746afd45 100644 --- a/core/admin/mailu/ui/templates/user/list.html +++ b/core/admin/mailu/ui/templates/user/list.html @@ -19,7 +19,8 @@ {% trans %}User settings{% endtrans %} {% trans %}Email{% endtrans %} {% trans %}Features{% endtrans %} - {% trans %}Quota{% endtrans %} + {% trans %}Storage Quota{% endtrans %} + {% trans %}Sending Quota{% endtrans %} {% trans %}Comment{% endtrans %} {% trans %}Created{% endtrans %} {% trans %}Last edit{% endtrans %} @@ -41,6 +42,8 @@ {% if user.enable_pop %}pop3{% endif %} {{ user.quota_bytes_used | filesizeformat }} / {{ (user.quota_bytes | filesizeformat) if user.quota_bytes else '∞' }} + {% set limiter = user.sender_limiter %} + {{ limiter.get_window_stats()[1] }} / {{ limiter.limit }} {{ user.comment or '-' }} {{ user.created_at }} {{ user.updated_at or '' }} diff --git a/core/nginx/conf/dhparam.pem b/core/nginx/conf/dhparam.pem index 3cf0fcbc..4f25f663 100644 --- a/core/nginx/conf/dhparam.pem +++ b/core/nginx/conf/dhparam.pem @@ -1,13 +1,11 @@ -----BEGIN DH PARAMETERS----- -MIICCAKCAgEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz -+8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a -87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 -YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi -7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD -ssbzSibBsu/6iGtCOGEfz9zeNVs7ZRkDW7w09N75nAI4YbRvydbmyQd62R0mkff3 -7lmMsPrBhtkcrv4TCYUTknC0EwyTvEN5RPT9RFLi103TZPLiHnH1S/9croKrnJ32 -nuhtK8UiNjoNq8Uhl5sN6todv5pC1cRITgq80Gv6U93vPBsg7j/VnXwl5B0rZp4e -8W5vUsMWTfT7eTDp5OWIV7asfV9C1p9tGHdjzx1VA0AEh/VbpX4xzHpxNciG77Qx -iu1qHgEtnmgyqQdgCpGBMMRtx3j5ca0AOAkpmaMzy4t6Gh25PXFAADwqTs6p+Y0K -zAqCkc3OyX3Pjsm1Wn+IpGtNtahR9EGC4caKAH5eZV9q//////////8CAQI= +MIIBiAKCAYEAtQlUSOKGjpdXJ154qmMEa1pEs+9CdSxWiZFkiXBJb0lTafOh8cfF +2IkcWSwzxWwjW4Ad26UQQFh1poGf2QBzVk2vuKCekYzPAs/WqH8VwiXBiWR5R9lh +v/+CkEBYuQOzAhXLN6ZGdPPa2sjdI49rlaIqyLJE4D0TI/VHYmC/vEwqkJUgaGrS +19LhHZimnmouvrnyBPyf00czXlMow0RnmYeHVZ7W5hu7t9TH9o3QAN/GKiFfxFj+ +RkdLM7beQdS0He5YeTaElM5l1YT5d5gHFbOzEQyKHd10ux+bgVcgUeVbBnI1SAIC +w53yc1PkDAiRijSP5j5aWq1djtJPheS13o35HyIf0cHzkNYhKfX5JWPj/cbgdM+C +FL1bnRc8sL5oxmkDoGJhiNZIf4n2WtS8Zu28gUgat6S+vCm/4yavIc/T1g6UiNKE +X41HPbsma/QWUwOL6S+b2qr+7rKqjI5TzVek8vBMellEV4mBvfQU3NDSQ4WvxbTq +ZEOgLPA178nrAgEC -----END DH PARAMETERS----- diff --git a/core/nginx/conf/nginx.conf b/core/nginx/conf/nginx.conf index 5158ca5c..9ce12980 100644 --- a/core/nginx/conf/nginx.conf +++ b/core/nginx/conf/nginx.conf @@ -250,6 +250,7 @@ mail { listen 10025; protocol smtp; smtp_auth plain; + auth_http_header Auth-Port 10025; } # Default IMAP server for the webmail (no encryption, but authentication) @@ -257,6 +258,7 @@ mail { listen 10143; protocol imap; smtp_auth plain; + auth_http_header Auth-Port 10043; } # SMTP is always enabled, to avoid losing emails when TLS is failing @@ -271,6 +273,7 @@ mail { {% endif %} protocol smtp; smtp_auth none; + auth_http_header Auth-Port 25; } # All other protocols are disabled if TLS is failing @@ -283,6 +286,7 @@ mail { {% endif %} protocol imap; imap_auth plain; + auth_http_header Auth-Port 143; } server { @@ -293,6 +297,7 @@ mail { {% endif %} protocol pop3; pop3_auth plain; + auth_http_header Auth-Port 110; } server { @@ -303,6 +308,7 @@ mail { {% endif %} protocol smtp; smtp_auth plain login; + auth_http_header Auth-Port 587; } {% if TLS %} @@ -311,6 +317,7 @@ mail { listen [::]:465 ssl; protocol smtp; smtp_auth plain login; + auth_http_header Auth-Port 465; } server { @@ -318,6 +325,7 @@ mail { listen [::]:993 ssl; protocol imap; imap_auth plain; + auth_http_header Auth-Port 993; } server { @@ -325,6 +333,7 @@ mail { listen [::]:995 ssl; protocol pop3; pop3_auth plain; + auth_http_header Auth-Port 995; } {% endif %} {% endif %} diff --git a/core/nginx/conf/tls.conf b/core/nginx/conf/tls.conf index 5d7ec031..f663bfd2 100644 --- a/core/nginx/conf/tls.conf +++ b/core/nginx/conf/tls.conf @@ -1,5 +1,10 @@ ssl_certificate {{ TLS[0] }}; ssl_certificate_key {{ TLS[1] }}; +{% if TLS_FLAVOR in ['letsencrypt','mail-letsencrypt'] %} +ssl_certificate {{ TLS[2] }}; +ssl_certificate_key {{ TLS[3] }}; +ssl_trusted_certificate /etc/ssl/certs/ca-cert-DST_Root_CA_X3.pem; +{% endif %} ssl_session_timeout 1d; ssl_session_tickets off; ssl_dhparam /conf/dhparam.pem; diff --git a/core/nginx/config.py b/core/nginx/config.py index 6fc9c082..9fa75877 100755 --- a/core/nginx/config.py +++ b/core/nginx/config.py @@ -26,11 +26,11 @@ cert_name = os.getenv("TLS_CERT_FILENAME", default="cert.pem") keypair_name = os.getenv("TLS_KEYPAIR_FILENAME", default="key.pem") args["TLS"] = { "cert": ("/certs/%s" % cert_name, "/certs/%s" % keypair_name), - "letsencrypt": ("/certs/letsencrypt/live/mailu/fullchain.pem", - "/certs/letsencrypt/live/mailu/privkey.pem"), + "letsencrypt": ("/certs/letsencrypt/live/mailu/nginx-chain.pem", + "/certs/letsencrypt/live/mailu/privkey.pem", "/certs/letsencrypt/live/mailu-ecdsa/nginx-chain.pem", "/certs/letsencrypt/live/mailu-ecdsa/privkey.pem"), "mail": ("/certs/%s" % cert_name, "/certs/%s" % keypair_name), - "mail-letsencrypt": ("/certs/letsencrypt/live/mailu/fullchain.pem", - "/certs/letsencrypt/live/mailu/privkey.pem"), + "mail-letsencrypt": ("/certs/letsencrypt/live/mailu/nginx-chain.pem", + "/certs/letsencrypt/live/mailu/privkey.pem", "/certs/letsencrypt/live/mailu-ecdsa/nginx-chain.pem", "/certs/letsencrypt/live/mailu-ecdsa/privkey.pem"), "notls": None }[args["TLS_FLAVOR"]] diff --git a/core/nginx/letsencrypt.py b/core/nginx/letsencrypt.py index 3fe8ea92..1dd2cba4 100755 --- a/core/nginx/letsencrypt.py +++ b/core/nginx/letsencrypt.py @@ -4,7 +4,6 @@ import os import time import subprocess - command = [ "certbot", "-n", "--agree-tos", # non-interactive @@ -14,16 +13,45 @@ command = [ "--cert-name", "mailu", "--preferred-challenges", "http", "--http-01-port", "8008", "--keep-until-expiring", - "--rsa-key-size", "4096", + "--config-dir", "/certs/letsencrypt", + "--post-hook", "/config.py" +] +command2 = [ + "certbot", + "-n", "--agree-tos", # non-interactive + "-d", os.environ["HOSTNAMES"], + "-m", "{}@{}".format(os.environ["POSTMASTER"], os.environ["DOMAIN"]), + "certonly", "--standalone", + "--cert-name", "mailu-ecdsa", + "--preferred-challenges", "http", "--http-01-port", "8008", + "--keep-until-expiring", + "--key-type", "ecdsa", "--config-dir", "/certs/letsencrypt", "--post-hook", "/config.py" ] +def format_for_nginx(fullchain, output): + """ We may want to strip ISRG Root X1 out + """ + certs = [] + with open(fullchain, 'r') as pem: + cert = '' + for line in pem: + cert += line + if '-----END CERTIFICATE-----' in line: + certs += [cert] + cert = '' + with open(output, 'w') as pem: + for cert in certs[:-1] if len(certs)>2 and os.getenv('LETSENCRYPT_SHORTCHAIN', default="False") else certs: + pem.write(cert) + # Wait for nginx to start time.sleep(5) -# Run certbot every hour +# Run certbot every day while True: subprocess.call(command) - time.sleep(3600) - + format_for_nginx('/certs/letsencrypt/live/mailu/fullchain.pem', '/certs/letsencrypt/live/mailu/nginx-chain.pem') + subprocess.call(command2) + format_for_nginx('/certs/letsencrypt/live/mailu-ecdsa/fullchain.pem', '/certs/letsencrypt/live/mailu-ecdsa/nginx-chain.pem') + time.sleep(86400) diff --git a/core/postfix/conf/main.cf b/core/postfix/conf/main.cf index e102f51a..e7a88a7c 100644 --- a/core/postfix/conf/main.cf +++ b/core/postfix/conf/main.cf @@ -33,7 +33,8 @@ relayhost = {{ RELAYHOST }} {% if RELAYUSER %} smtp_sasl_auth_enable = yes smtp_sasl_password_maps = lmdb:/etc/postfix/sasl_passwd -smtp_sasl_security_options = noanonymous +smtp_sasl_security_options = noanonymous, noplaintext +smtp_sasl_tls_security_options = noanonymous {% endif %} # Recipient delimiter for extended addresses @@ -57,10 +58,11 @@ tls_ssl_options = NO_COMPRESSION, NO_TICKET # 2. not all will have and up-to-date TLS stack. smtp_tls_mandatory_protocols = !SSLv2, !SSLv3 smtp_tls_protocols =!SSLv2,!SSLv3 -smtp_tls_session_cache_database = lmdb:${data_directory}/smtp_scache smtp_tls_security_level = {{ OUTBOUND_TLS_LEVEL|default('may') }} smtp_tls_policy_maps=hash:/etc/postfix/tls_policy.map smtp_tls_CApath = /etc/ssl/certs +smtp_tls_session_cache_database = lmdb:/dev/shm/postfix/smtp_scache +smtpd_tls_session_cache_database = lmdb:/dev/shm/postfix/smtpd_scache ############### # Virtual @@ -101,6 +103,8 @@ smtpd_sender_login_maps = ${podop}senderlogin # Restrictions for incoming SMTP, other restrictions are applied in master.cf smtpd_helo_required = yes +check_ratelimit = check_sasl_access ${podop}senderrate + smtpd_client_restrictions = permit_mynetworks, check_sender_access ${podop}senderaccess, diff --git a/core/postfix/conf/master.cf b/core/postfix/conf/master.cf index e45a8ccf..15613476 100644 --- a/core/postfix/conf/master.cf +++ b/core/postfix/conf/master.cf @@ -7,7 +7,8 @@ smtp inet n - n - - smtpd # Internal SMTP service 10025 inet n - n - - smtpd -o smtpd_sasl_auth_enable=yes - -o smtpd_client_restrictions=reject_unlisted_sender,reject_authenticated_sender_login_mismatch,permit + -o smtpd_discard_ehlo_keywords=pipelining + -o smtpd_client_restrictions=$check_ratelimit,reject_unlisted_sender,reject_authenticated_sender_login_mismatch,permit -o smtpd_reject_unlisted_recipient={% if REJECT_UNLISTED_RECIPIENT %}{{ REJECT_UNLISTED_RECIPIENT }}{% else %}no{% endif %} -o cleanup_service_name=outclean outclean unix n - n - 0 cleanup diff --git a/core/postfix/start.py b/core/postfix/start.py index 83954192..799d42f5 100755 --- a/core/postfix/start.py +++ b/core/postfix/start.py @@ -15,6 +15,7 @@ log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING")) def start_podop(): os.setuid(getpwnam('postfix').pw_uid) + os.mkdir('/dev/shm/postfix',mode=0o700) url = "http://" + os.environ["ADMIN_ADDRESS"] + "/internal/postfix/" # TODO: Remove verbosity setting from Podop? run_server(0, "postfix", "/tmp/podop.socket", [ @@ -25,7 +26,8 @@ def start_podop(): ("recipientmap", "url", url + "recipient/map/§"), ("sendermap", "url", url + "sender/map/§"), ("senderaccess", "url", url + "sender/access/§"), - ("senderlogin", "url", url + "sender/login/§") + ("senderlogin", "url", url + "sender/login/§"), + ("senderrate", "url", url + "sender/rate/§") ]) def is_valid_postconf_line(line): diff --git a/docs/configuration.rst b/docs/configuration.rst index 88e60095..27f8db7d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -37,6 +37,8 @@ The ``POSTMASTER`` is the local part of the postmaster email address. It is recommended to setup a generic value and later configure a mail alias for that address. +The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses that are allowed to send emails from any existing address (spoofing the sender). + The ``AUTH_RATELIMIT`` holds a security setting for fighting attackers that try to guess user passwords. The value is the limit of failed authentication attempts that a single IP address can perform against IMAP, POP and SMTP authentication endpoints. @@ -163,6 +165,11 @@ See the `python docs`_ for more information. .. _`python docs`: https://docs.python.org/3.6/library/logging.html#logging-levels +The ``LETSENCRYPT_SHORTCHAIN`` (default: False) setting controls whether we send the ISRG Root X1 certificate in TLS handshakes. This is required for `android handsets older than 7.1.1` but slows down the performance of modern devices. + +.. _`android handsets older than 7.1.1`: https://community.letsencrypt.org/t/production-chain-changes/150739 + + Antivirus settings ------------------ diff --git a/docs/webadministration.rst b/docs/webadministration.rst index 86ce41c0..03b07ba2 100644 --- a/docs/webadministration.rst +++ b/docs/webadministration.rst @@ -315,6 +315,21 @@ This page is also accessible for domain managers. On the users page new users ca * Fetched accounts. Access the fetched accounts page of the user. See the :ref:`fetched accounts page ` for more information. +This page also shows an overview of the following settings of an user: + +* Email. The email address of the user. + +* Features. Shows if IMAP or POP3 access is enabled. + +* Storage quota. Shows how much assigned storage has been consumed. + +* Sending Quota. The sending quota is the limit of messages a single user can send per day. + +* Comment. A desription for the user. + +* Created. Date when the user was created. + +* Last edit. Last date when the user was modified. .. _webadministration_add_user: @@ -334,7 +349,7 @@ For adding a new user the following options can be configured. * Enabled. Tick this checkbox to enable the user account. When an user is disabled, the user is unable to login to the Admin GUI or webmail or access his email via IMAP/POP3 or send mail. The email inbox of the user is still retained. This option can be used to temporarily suspend an user account. -* Quota. The maximum quota for the user's email box. +* Storage Quota. The maximum quota for the user's email box. * Allow IMAP access. When ticked, allows email retrieval via the IMAP protocol. diff --git a/optional/unbound/Dockerfile b/optional/unbound/Dockerfile index 2b472d44..abb45420 100644 --- a/optional/unbound/Dockerfile +++ b/optional/unbound/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.12 FROM $DISTRO # python3 shared with most images RUN apk add --no-cache \ diff --git a/setup/flavors/compose/mailu.env b/setup/flavors/compose/mailu.env index d45f5517..52f4ee04 100644 --- a/setup/flavors/compose/mailu.env +++ b/setup/flavors/compose/mailu.env @@ -62,6 +62,11 @@ ANTIVIRUS={{ antivirus_enabled or 'none' }} # Max attachment size will be 33% smaller MESSAGE_SIZE_LIMIT={{ message_size_limit or '50000000' }} +# Message rate limit (per user) +{% if message_ratelimit_pd > '0' %} +MESSAGE_RATELIMIT={{ message_ratelimit_pd }}/day +{% endif %} + # Networks granted relay permissions # Use this with care, all hosts in this networks will be able to send mail without authentication! RELAYNETS= diff --git a/setup/templates/steps/config.html b/setup/templates/steps/config.html index 72b83915..f532f757 100644 --- a/setup/templates/steps/config.html +++ b/setup/templates/steps/config.html @@ -55,6 +55,13 @@ Or in plain english: if receivers start to classify your mail as spam, this post

+
+ + +

/ day +

+
+