diff --git a/.gitignore b/.gitignore index e12944a..3f88a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,8 @@ target/ # PyCharm .idea/ + +#KDE +*.kdev4 + +installer.cfg diff --git a/README.rst b/README.rst index 3b1759d..7bdeb2c 100644 --- a/README.rst +++ b/README.rst @@ -9,8 +9,8 @@ An installer which deploy a complete mail server based on Modoboa. This tool is still in beta stage, it has been tested on: - * Debian 10 and upper - * Ubuntu Bionic Beaver (18.04) and upper + * Debian 12 and upper + * Ubuntu Focal Fossa (20.04) and upper .. warning:: @@ -43,7 +43,7 @@ The following components are installed by the installer: * Nginx and uWSGI * Postfix * Dovecot -* Amavis (with SpamAssassin and ClamAV) +* Amavis (with SpamAssassin and ClamAV) or Rspamd * automx (autoconfiguration service) * OpenDKIM * Radicale (CalDAV and CardDAV server) @@ -229,6 +229,22 @@ If you want to use already generated certs, simply edit the tls_cert_file_path = *path to tls fullchain file* tls_key_file_path = *path to tls key file* +Antispam +======== + +You have 3 options regarding antispam : disabled, Amavis, Rspamd + +Amavis +------ + +Amavis + +Rspamd +------ + +Rspamd + + .. |workflow| image:: https://github.com/modoboa/modoboa-installer/workflows/Modoboa%20installer/badge.svg .. |codecov| image:: https://codecov.io/gh/modoboa/modoboa-installer/graph/badge.svg?token=Fo2o1GdHZq :target: https://codecov.io/gh/modoboa/modoboa-installer diff --git a/checks.py b/modoboa_installer/checks.py similarity index 95% rename from checks.py rename to modoboa_installer/checks.py index a8022a7..5f2cb0f 100644 --- a/checks.py +++ b/modoboa_installer/checks.py @@ -26,7 +26,7 @@ def check_version(): "Check README file for instructions about how to update.\n" "No support will be provided without an up-to-date installer!" ) - answer = utils.user_input("Continue anyway? (Y/n) ") + answer = utils.user_input("Continue anyway? (y/N) ") if not answer.lower().startswith("y"): sys.exit(0) else: diff --git a/modoboa_installer/compatibility_matrix.py b/modoboa_installer/compatibility_matrix.py index 9377c49..ac68194 100644 --- a/modoboa_installer/compatibility_matrix.py +++ b/modoboa_installer/compatibility_matrix.py @@ -37,3 +37,10 @@ REMOVED_EXTENSIONS = { "modoboa-radicale": "2.4.0", "modoboa-webmail": "2.4.0", } + +APP_INCOMPATIBILITY = { + "opendkim": ["rspamd"], + "amavis": ["rspamd"], + "postwhite": ["rspamd"], + "spamassassin": ["rspamd"] +} diff --git a/modoboa_installer/config_dict_template.py b/modoboa_installer/config_dict_template.py index 72e4ebd..4e3b918 100644 --- a/modoboa_installer/config_dict_template.py +++ b/modoboa_installer/config_dict_template.py @@ -27,6 +27,26 @@ ConfigDictTemplate = [ } ] }, + { + "name": "antispam", + "values": [ + { + "option": "enabled", + "default": "true", + "customizable": True, + "values": ["true", "false"], + "question": "Do you want to setup an antispam utility?" + }, + { + "option": "type", + "default": "amavis", + "customizable": True, + "question": "Please select your antispam utility", + "values": ["rspamd", "amavis"], + "if": ["antispam.enabled=true"] + } + ] + }, { "name": "certificate", "values": [ @@ -50,7 +70,7 @@ ConfigDictTemplate = [ }, { "name": "letsencrypt", - "if": "certificate.type=letsencrypt", + "if": ["certificate.type=letsencrypt"], "values": [ { "option": "email", @@ -85,7 +105,7 @@ ConfigDictTemplate = [ }, { "name": "postgres", - "if": "database.engine=postgres", + "if": ["database.engine=postgres"], "values": [ { "option": "user", @@ -101,7 +121,7 @@ ConfigDictTemplate = [ }, { "name": "mysql", - "if": "database.engine=mysql", + "if": ["database.engine=mysql"], "values": [ { "option": "user", @@ -185,6 +205,13 @@ ConfigDictTemplate = [ "customizable": True, "question": "Please enter Modoboa db password", }, + { + "option": "cron_error_recipient", + "default": "root", + "customizable": True, + "question": + "Please enter a mail recipient for cron error reports" + }, { "option": "extensions", "default": "" @@ -224,12 +251,60 @@ ConfigDictTemplate = [ }, ] }, + { + "name": "rspamd", + "if": ["antispam.enabled=true", "antispam.type=rspamd"], + "values": [ + { + "option": "enabled", + "default": ["antispam.enabled=true", "antispam.type=rspamd"], + }, + { + "option": "user", + "default": "_rspamd", + }, + { + "option": "password", + "default": make_password, + "customizable": True, + "question": "Please enter Rspamd interface password", + }, + { + "option": "dnsbl", + "default": "true", + }, + { + "option": "dkim_keys_storage_dir", + "default": "/var/lib/dkim" + }, + { + "option": "key_map_path", + "default": "/var/lib/dkim/keys.path.map" + }, + { + "option": "selector_map_path", + "default": "/var/lib/dkim/selectors.path.map" + }, + { + "option": "greylisting", + "default": "true" + }, + { + "option": "whitelist_auth", + "default": "true" + }, + { + "option": "whitelist_auth_weigth", + "default": "-5" + } + ], + }, { "name": "amavis", "values": [ { "option": "enabled", - "default": "true", + "default": ["antispam.enabled=true", "antispam.type=amavis"], }, { "option": "user", @@ -250,8 +325,6 @@ ConfigDictTemplate = [ { "option": "dbpassword", "default": make_password, - "customizable": True, - "question": "Please enter amavis db password" }, ], }, @@ -299,6 +372,14 @@ ConfigDictTemplate = [ "option": "postmaster_address", "default": "postmaster@%(domain)s", }, + { + "option": "radicale_auth_socket_path", + "default": "/var/run/dovecot/auth-radicale", + }, + { + "option": "move_spam_to_junk", + "default": "true", + }, ] }, { @@ -319,7 +400,7 @@ ConfigDictTemplate = [ "values": [ { "option": "enabled", - "default": "true", + "default": "false", }, { "option": "config_dir", @@ -353,7 +434,7 @@ ConfigDictTemplate = [ "values": [ { "option": "enabled", - "default": "true", + "default": ["antispam.enabled=true", "antispam.type=amavis"], }, { "option": "config_dir", @@ -363,10 +444,11 @@ ConfigDictTemplate = [ }, { "name": "spamassassin", + "if": ["antispam.enabled=true", "antispam.type=amavis"], "values": [ { "option": "enabled", - "default": "true", + "default": ["antispam.enabled=true", "antispam.type=amavis"], }, { "option": "config_dir", @@ -432,10 +514,11 @@ ConfigDictTemplate = [ }, { "name": "opendkim", + "if": ["antispam.enabled=true", "antispam.type=amavis"], "values": [ { "option": "enabled", - "default": "true", + "default": ["antispam.enabled=true", "antispam.type=amavis"], }, { "option": "user", diff --git a/modoboa_installer/disclaimers.py b/modoboa_installer/disclaimers.py new file mode 100644 index 0000000..9faff69 --- /dev/null +++ b/modoboa_installer/disclaimers.py @@ -0,0 +1,51 @@ +from . import utils + + +def installation_disclaimer(args, config): + """Display installation disclaimer.""" + hostname = config.get("general", "hostname") + utils.printcolor( + "Notice:\n" + "It is recommanded to run this installer on a FRESHLY installed server.\n" + "(ie. with nothing special already installed on it)\n", + utils.CYAN + ) + utils.printcolor( + "Warning:\n" + "Before you start the installation, please make sure the following " + "DNS records exist for domain '{}':\n" + " {} IN A \n" + " @ IN MX {}.\n".format( + args.domain, + hostname.replace(".{}".format(args.domain), ""), + hostname + ), + utils.YELLOW + ) + utils.printcolor( + "Your mail server will be installed with the following components:", + utils.BLUE) + + +def upgrade_disclaimer(config): + """Display upgrade disclaimer.""" + utils.printcolor( + "Your mail server is about to be upgraded and the following components" + " will be impacted:", utils.BLUE + ) + + +def backup_disclaimer(): + """Display backup disclamer. """ + utils.printcolor( + "Your mail server will be backed up locally.\n" + " !! You should really transfer the backup somewhere else...\n" + " !! Custom configuration (like for postfix) won't be saved.", utils.BLUE) + + +def restore_disclaimer(): + """Display restore disclamer. """ + utils.printcolor( + "You are about to restore a previous installation of Modoboa.\n" + "If a new version has been released in between, please update your database!", + utils.BLUE) diff --git a/modoboa_installer/package.py b/modoboa_installer/package.py index 58ee940..8301464 100644 --- a/modoboa_installer/package.py +++ b/modoboa_installer/package.py @@ -2,6 +2,8 @@ import re +from os.path import isfile as file_exists + from . import utils @@ -49,6 +51,29 @@ class DEBPackage(Package): def restore_system(self): utils.exec_cmd("rm -f {}".format(self.policy_file)) + def add_custom_repository(self, + name: str, + url: str, + key_url: str, + codename: str, + with_source: bool = True): + key_file = f"/etc/apt/keyrings/{name}.gpg" + utils.exec_cmd( + f"wget -O - {key_url} | gpg --dearmor | tee {key_file} > /dev/null" + ) + line_types = ["deb"] + if with_source: + line_types.append("deb-src") + for line_type in line_types: + line = ( + f"{line_type} [arch=amd64 signed-by={key_file}] " + f"{url} {codename} main" + ) + target_file = f"/etc/apt/sources.list.d/{name}.list" + tee_option = "-a" if file_exists(target_file) else "" + utils.exec_cmd(f'echo "{line}" | tee {tee_option} {target_file}') + self.index_updated = False + def update(self, force=False): """Update local cache.""" if self.index_updated and not force: @@ -89,7 +114,7 @@ class RPMPackage(Package): def __init__(self, dist_name): """Initialize backend.""" - super(RPMPackage, self).__init__(dist_name) + super().__init__(dist_name) if "centos" in dist_name: self.install("epel-release") diff --git a/modoboa_installer/scripts/clamav.py b/modoboa_installer/scripts/clamav.py index 2ff4868..181eaf3 100644 --- a/modoboa_installer/scripts/clamav.py +++ b/modoboa_installer/scripts/clamav.py @@ -42,9 +42,10 @@ class Clamav(base.Installer): """Additional tasks.""" if package.backend.FORMAT == "deb": user = self.config.get(self.appname, "user") - system.add_user_to_group( - user, self.config.get("amavis", "user") - ) + if self.config.getboolean("amavis", "enabled"): + system.add_user_to_group( + user, self.config.get("amavis", "user") + ) pattern = ( "s/^AllowSupplementaryGroups false/" "AllowSupplementaryGroups true/") diff --git a/modoboa_installer/scripts/dovecot.py b/modoboa_installer/scripts/dovecot.py index 6c38e85..00dd8c7 100644 --- a/modoboa_installer/scripts/dovecot.py +++ b/modoboa_installer/scripts/dovecot.py @@ -4,6 +4,7 @@ import glob import os import pwd import shutil +import stat import uuid from .. import database @@ -15,6 +16,7 @@ from . import base class Dovecot(base.Installer): + """Dovecot installer.""" appname = "dovecot" @@ -26,9 +28,13 @@ class Dovecot(base.Installer): "dovecot", "dovecot-pigeonhole"] } config_files = [ - "dovecot.conf", "dovecot-dict-sql.conf.ext", "conf.d/10-ssl.conf", - "conf.d/10-master.conf", "conf.d/20-lmtp.conf", "conf.d/10-ssl-keys.try", - "conf.d/dovecot-oauth2.conf.ext" + "dovecot.conf", + "dovecot-dict-sql.conf.ext", + "conf.d/10-ssl.conf", + "conf.d/10-master.conf", + "conf.d/20-lmtp.conf", + "conf.d/10-ssl-keys.try", + "conf.d/dovecot-oauth2.conf.ext", ] with_user = True @@ -40,7 +46,15 @@ class Dovecot(base.Installer): def get_config_files(self): """Additional config files.""" - return self.config_files + [ + _config_files = self.config_files + + if self.app_config["move_spam_to_junk"]: + _config_files += [ + "conf.d/custom_after_sieve/spam-to-junk.sieve", + "conf.d/90-sieve.conf", + ] + + return _config_files + [ "dovecot-sql-{}.conf.ext=dovecot-sql.conf.ext" .format(self.dbengine), "dovecot-sql-master-{}.conf.ext=dovecot-sql-master.conf.ext" @@ -117,10 +131,22 @@ class Dovecot(base.Installer): "ssl_protocol_parameter": ssl_protocol_parameter, "modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#", "not_modoboa_2_2_or_greater": "" if not self.modoboa_2_2_or_greater else "#", + "do_move_spam_to_junk": "" if self.app_config["move_spam_to_junk"] else "#", "oauth2_introspection_url": oauth2_introspection_url }) return context + def install_config_files(self): + """Create sieve dir if needed.""" + if self.app_config["move_spam_to_junk"]: + utils.mkdir_safe( + f"{self.config_dir}/conf.d/custom_after_sieve", + stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH, + 0, 0 + ) + super().install_config_files() + def post_run(self): """Additional tasks.""" if self.dbengine == "postgres": @@ -137,7 +163,8 @@ class Dovecot(base.Installer): self.get_file_path("fix_modoboa_postgres_schema.sql") ) for f in glob.glob("{}/*".format(self.get_file_path("conf.d"))): - utils.copy_file(f, "{}/conf.d".format(self.config_dir)) + if os.path.isfile(f): + utils.copy_file(f, "{}/conf.d".format(self.config_dir)) # Make postlogin script executable utils.exec_cmd("chmod +x /usr/local/bin/postlogin.sh") # Only root should have read access to the 10-ssl-keys.try @@ -145,6 +172,10 @@ class Dovecot(base.Installer): utils.exec_cmd("chmod 600 /etc/dovecot/conf.d/10-ssl-keys.try") # Add mailboxes user to dovecot group for modoboa mailbox commands. # See https://github.com/modoboa/modoboa/issues/2157. + if self.app_config["move_spam_to_junk"]: + # Compile sieve script + sieve_file = f"{self.config_dir}/conf.d/custom_after_sieve/spam-to-junk.sieve" + utils.exec_cmd(f"/usr/bin/sievec {sieve_file}") system.add_user_to_group(self.mailboxes_owner, 'dovecot') def restart_daemon(self): diff --git a/modoboa_installer/scripts/files/dovecot/conf.d/90-sieve.conf b/modoboa_installer/scripts/files/dovecot/conf.d/90-sieve.conf.tpl similarity index 98% rename from modoboa_installer/scripts/files/dovecot/conf.d/90-sieve.conf rename to modoboa_installer/scripts/files/dovecot/conf.d/90-sieve.conf.tpl index 35a9f5d..480d2c2 100644 --- a/modoboa_installer/scripts/files/dovecot/conf.d/90-sieve.conf +++ b/modoboa_installer/scripts/files/dovecot/conf.d/90-sieve.conf.tpl @@ -38,7 +38,7 @@ plugin { # Identical to sieve_before, only the specified scripts are executed after the # user's script (only when keep is still in effect!). Multiple script file or # directory paths can be specified by appending an increasing number. - #sieve_after = + %{do_move_spam_to_junk}sieve_after = /etc/dovecot/conf.d/custom_after_sieve #sieve_after2 = #sieve_after2 = (etc...) diff --git a/modoboa_installer/scripts/files/dovecot/conf.d/custom_after_sieve/spam-to-junk.sieve.tpl b/modoboa_installer/scripts/files/dovecot/conf.d/custom_after_sieve/spam-to-junk.sieve.tpl new file mode 100644 index 0000000..8eb572c --- /dev/null +++ b/modoboa_installer/scripts/files/dovecot/conf.d/custom_after_sieve/spam-to-junk.sieve.tpl @@ -0,0 +1,4 @@ +require "fileinto"; +if header :contains "X-Spam-Status" "Yes" { + fileinto "Junk"; +} diff --git a/modoboa_installer/scripts/files/modoboa/crontab.tpl b/modoboa_installer/scripts/files/modoboa/crontab.tpl index 84d9427..f5d36d2 100644 --- a/modoboa_installer/scripts/files/modoboa/crontab.tpl +++ b/modoboa_installer/scripts/files/modoboa/crontab.tpl @@ -3,6 +3,7 @@ # PYTHON=%{venv_path}/bin/python INSTANCE=%{instance_path} +MAILTO=%{cron_error_recipient} # Operations on mailboxes %{dovecot_enabled}* * * * * %{dovecot_mailboxes_owner} $PYTHON $INSTANCE/manage.py handle_mailbox_operations diff --git a/modoboa_installer/scripts/files/modoboa/supervisor-rq-dkim.tpl b/modoboa_installer/scripts/files/modoboa/supervisor-rq-dkim.tpl index 42d9bd0..531650e 100644 --- a/modoboa_installer/scripts/files/modoboa/supervisor-rq-dkim.tpl +++ b/modoboa_installer/scripts/files/modoboa/supervisor-rq-dkim.tpl @@ -3,7 +3,7 @@ autostart=true autorestart=true command=%{venv_path}/bin/python %{home_dir}/instance/manage.py rqworker dkim directory=%{home_dir} -user=%{opendkim_user} +user=%{dkim_user} redirect_stderr=true numprocs=1 stopsignal=TERM diff --git a/modoboa_installer/scripts/files/nginx/modoboa.conf.tpl b/modoboa_installer/scripts/files/nginx/modoboa.conf.tpl index 725402c..c52e710 100644 --- a/modoboa_installer/scripts/files/nginx/modoboa.conf.tpl +++ b/modoboa_installer/scripts/files/nginx/modoboa.conf.tpl @@ -37,6 +37,13 @@ server { try_files $uri $uri/ =404; } +%{rspamd_enabled} location /rspamd/ { +%{rspamd_enabled} proxy_pass http://localhost:11334/; +%{rspamd_enabled} +%{rspamd_enabled} proxy_set_header Host $host; +%{rspamd_enabled} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +%{rspamd_enabled} } + location ~ ^/(api|accounts) { include uwsgi_params; uwsgi_param UWSGI_SCRIPT instance.wsgi:application; diff --git a/modoboa_installer/scripts/files/postfix/anonymize_headers.pcre.tpl b/modoboa_installer/scripts/files/postfix/anonymize_headers.pcre.tpl new file mode 100644 index 0000000..b4eef17 --- /dev/null +++ b/modoboa_installer/scripts/files/postfix/anonymize_headers.pcre.tpl @@ -0,0 +1,11 @@ +if /^\s*Received:.*Authenticated sender.*\(Postfix\)/ +/^Received: from .*? \([\w\-.]* \[.*?\]\)(.*|\n.*)\(Authenticated sender: (.+)\)\s+by.+\(Postfix\) with (.*)/ + REPLACE Received: from [127.0.0.1] (localhost [127.0.0.1]) by localhost (Mailerdaemon) with $3 +endif +if /^\s*Received: from .*rspamd.localhost .*\(Postfix\)/ +/^Received: from.* (.*|\n.*)\((.+) (.+)\)\s+by (.+) \(Postfix\) with (.*)/ + REPLACE Received: from rspamd (rspamd $3) by $4 (Postfix) with $5 +endif +/^\s*X-Enigmail/ IGNORE +/^\s*X-Originating-IP/ IGNORE +/^\s*X-Forward/ IGNORE diff --git a/modoboa_installer/scripts/files/postfix/main.cf.tpl b/modoboa_installer/scripts/files/postfix/main.cf.tpl index 294c2a0..2070134 100644 --- a/modoboa_installer/scripts/files/postfix/main.cf.tpl +++ b/modoboa_installer/scripts/files/postfix/main.cf.tpl @@ -122,10 +122,19 @@ strict_rfc821_envelopes = yes %{opendkim_enabled}milter_default_action = accept %{opendkim_enabled}milter_content_timeout = 30s +# Rspamd setup +%{rspamd_enabled}smtpd_milters = inet:localhost:11332 +%{rspamd_enabled}non_smtpd_milters = inet:localhost:11332 +%{rspamd_enabled}milter_default_action = accept +%{rspamd_enabled}milter_protocol = 6 + # List of authorized senders smtpd_sender_login_maps = proxy:%{db_driver}:/etc/postfix/sql-sender-login-map.cf +# Add authenticated header to hide public client IP +smtpd_sasl_authenticated_header = yes + # Recipient restriction rules smtpd_recipient_restrictions = check_policy_service inet:127.0.0.1:9999 @@ -142,27 +151,27 @@ smtpd_recipient_restrictions = ## Postcreen settings # -postscreen_access_list = - permit_mynetworks - cidr:/etc/postfix/postscreen_spf_whitelist.cidr -postscreen_blacklist_action = enforce +%{rspamd_disabled}postscreen_access_list = +%{rspamd_disabled} permit_mynetworks +%{rspamd_disabled} cidr:/etc/postfix/postscreen_spf_whitelist.cidr +%{rspamd_disabled}postscreen_blacklist_action = enforce # Use some DNSBL -postscreen_dnsbl_sites = - zen.spamhaus.org=127.0.0.[2..11]*3 - bl.spameatingmonkey.net=127.0.0.2*2 - bl.spamcop.net=127.0.0.2 -postscreen_dnsbl_threshold = 3 -postscreen_dnsbl_action = enforce +%{rspamd_disabled}postscreen_dnsbl_sites = +%{rspamd_disabled} zen.spamhaus.org=127.0.0.[2..11]*3 +%{rspamd_disabled} bl.spameatingmonkey.net=127.0.0.2*2 +%{rspamd_disabled} bl.spamcop.net=127.0.0.2 +%{rspamd_disabled}postscreen_dnsbl_threshold = 3 +%{rspamd_disabled}postscreen_dnsbl_action = enforce -postscreen_greet_banner = Welcome, please wait... -postscreen_greet_action = enforce +%{rspamd_disabled}postscreen_greet_banner = Welcome, please wait... +%{rspamd_disabled}postscreen_greet_action = enforce -postscreen_pipelining_enable = yes -postscreen_pipelining_action = enforce +%{rspamd_disabled}postscreen_pipelining_enable = yes +%{rspamd_disabled}postscreen_pipelining_action = enforce -postscreen_non_smtp_command_enable = yes -postscreen_non_smtp_command_action = enforce +%{rspamd_disabled}postscreen_non_smtp_command_enable = yes +%{rspamd_disabled}postscreen_non_smtp_command_action = enforce -postscreen_bare_newline_enable = yes -postscreen_bare_newline_action = enforce +%{rspamd_disabled}postscreen_bare_newline_enable = yes +%{rspamd_disabled}postscreen_bare_newline_action = enforce diff --git a/modoboa_installer/scripts/files/postfix/master.cf.tpl b/modoboa_installer/scripts/files/postfix/master.cf.tpl index 72b2369..c35b4f4 100644 --- a/modoboa_installer/scripts/files/postfix/master.cf.tpl +++ b/modoboa_installer/scripts/files/postfix/master.cf.tpl @@ -9,7 +9,8 @@ # service type private unpriv chroot wakeup maxproc command + args # (yes) (yes) (yes) (never) (100) # ========================================================================== -smtp inet n - - - 1 postscreen +%{rspamd_disabled}smtp inet n - - - 1 postscreen +%{rspamd_enabled}smtp inet n - - - - smtpd smtpd pass - - - - - smtpd %{amavis_enabled} -o smtpd_proxy_filter=inet:[127.0.0.1]:10024 %{amavis_enabled} -o smtpd_proxy_options=speed_adjust @@ -26,6 +27,7 @@ submission inet n - - - - smtpd -o smtpd_helo_restrictions= -o smtpd_sender_restrictions=reject_sender_login_mismatch -o milter_macro_daemon_name=ORIGINATING + -o cleanup_service_name=ascleanup %{amavis_enabled} -o smtpd_proxy_filter=inet:[127.0.0.1]:10026 #smtps inet n - - - - smtpd # -o syslog_name=postfix/smtps @@ -41,6 +43,8 @@ submission inet n - - - - smtpd #628 inet n - - - - qmqpd pickup unix n - - 60 1 pickup cleanup unix n - - - 0 cleanup +ascleanup unix n - - - 0 cleanup + -o header_checks=pcre:/etc/postfix/anonymize_headers.pcre qmgr unix n - n 300 1 qmgr #qmgr unix n - n 300 1 oqmgr tlsmgr unix - - - 1000? 1 tlsmgr diff --git a/modoboa_installer/scripts/files/rspamd/local.d/antivirus.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/antivirus.conf.tpl new file mode 100644 index 0000000..f1d98eb --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/antivirus.conf.tpl @@ -0,0 +1,14 @@ +clamav { + scan_mime_parts = true; + scan_text_mime = true; + scan_image_mime = true; + retransmits = 2; + timeout = 30; + symbol = "CLAM_VIRUS"; + type = "clamav"; + servers = "127.0.0.1:3310" + patterns { + # symbol_name = "pattern"; + JUST_EICAR = "Test.EICAR"; + } +} diff --git a/modoboa_installer/scripts/files/rspamd/local.d/arc.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/arc.conf.tpl new file mode 100644 index 0000000..3dcf992 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/arc.conf.tpl @@ -0,0 +1,3 @@ +try_fallback = false; +selector_map = "%selector_map_path"; +path_map = "%key_map_path"; diff --git a/modoboa_installer/scripts/files/rspamd/local.d/dkim_signing.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/dkim_signing.conf.tpl new file mode 100644 index 0000000..3dcf992 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/dkim_signing.conf.tpl @@ -0,0 +1,3 @@ +try_fallback = false; +selector_map = "%selector_map_path"; +path_map = "%key_map_path"; diff --git a/modoboa_installer/scripts/files/rspamd/local.d/dmarc.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/dmarc.conf.tpl new file mode 100644 index 0000000..bfe456a --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/dmarc.conf.tpl @@ -0,0 +1,21 @@ +reporting { + # Required attributes + enabled = true; # Enable reports in general + email = 'postmaster@%hostname'; # Source of DMARC reports + domain = '%hostname'; # Domain to serve + org_name = '%hostname'; # Organisation + # Optional parameters + #bcc_addrs = ["postmaster@example.com"]; # additional addresses to copy on reports + report_local_controller = false; # Store reports for local/controller scans (for testing only) + #helo = 'rspamd.localhost'; # Helo used in SMTP dialog + #smtp = '127.0.0.1'; # SMTP server IP + #smtp_port = 25; # SMTP server port + from_name = '%hostname DMARC REPORT'; # SMTP FROM + msgid_from = 'rspamd'; # Msgid format + #max_entries = 1k; # Maxiumum amount of entries per domain + #keys_expire = 2d; # Expire date for Redis keys + #only_domains = '/path/to/map'; # Only store reports from domains or eSLDs listed in this map + # Available from 3.3 + #exclude_domains = '/path/to/map'; # Exclude reports from domains or eSLDs listed in this map + #exclude_domains = ["example.com", "another.com"]; # Alternative, use array to exclude reports from domains or eSLDs +} diff --git a/modoboa_installer/scripts/files/rspamd/local.d/force_actions.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/force_actions.conf.tpl new file mode 100644 index 0000000..6a1b331 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/force_actions.conf.tpl @@ -0,0 +1,5 @@ +rules { + DMARC_POLICY_QUARANTINE { + action = "add header"; + } +} diff --git a/modoboa_installer/scripts/files/rspamd/local.d/greylist.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/greylist.conf.tpl new file mode 100644 index 0000000..bf90f46 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/greylist.conf.tpl @@ -0,0 +1,2 @@ +%{greylisting_disabled}enabled = false; +servers = "127.0.0.1:6379"; diff --git a/modoboa_installer/scripts/files/rspamd/local.d/groups.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/groups.conf.tpl new file mode 100644 index 0000000..0e10663 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/groups.conf.tpl @@ -0,0 +1,5 @@ +symbols { + "WHITELIST_AUTHENTICATED" { + weight = %whitelist_auth_weigth; + } +} diff --git a/modoboa_installer/scripts/files/rspamd/local.d/metrics.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/metrics.conf.tpl new file mode 100644 index 0000000..896e746 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/metrics.conf.tpl @@ -0,0 +1,20 @@ +actions { + reject = 15; # normal value is 15, 150 so it will never be rejected + add_header = 6; # set to 0.1 for testing, 6 for normal operation. + rewrite_subject = 8; # Default: 8 + greylist = 4; # Default: 4 +} + +group "antivirus" { + symbol "JUST_EICAR" { + weight = 10; + description = "Eicar test signature"; + } + symbol "CLAM_VIRUS_FAIL" { + weight = 0; + } + symbol "CLAM_VIRUS" { + weight = 10; + description = "ClamAV found a Virus"; + } +} diff --git a/modoboa_installer/scripts/files/rspamd/local.d/milter_headers.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/milter_headers.conf.tpl new file mode 100644 index 0000000..e0b3743 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/milter_headers.conf.tpl @@ -0,0 +1,16 @@ +use = ["x-spam-status","x-virus","authentication-results" ]; +extended_spam_headers = false; +skip_local = false; +skip_authenticated = false; + +routines { + x-virus { + header = "X-Virus"; + remove = 1; + symbols = ["CLAM_VIRUS", "JUST_EICAR"]; + } +} + + + + diff --git a/modoboa_installer/scripts/files/rspamd/local.d/mx_check.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/mx_check.conf.tpl new file mode 100644 index 0000000..1ead4ee --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/mx_check.conf.tpl @@ -0,0 +1 @@ +enabled = true; diff --git a/modoboa_installer/scripts/files/rspamd/local.d/rbl.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/rbl.conf.tpl new file mode 100644 index 0000000..35b23ba --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/rbl.conf.tpl @@ -0,0 +1,6 @@ +# to disable all predefined rules if the user doesn't want dnsbl + +url_whitelist = []; + +rbls { +} diff --git a/modoboa_installer/scripts/files/rspamd/local.d/redis.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/redis.conf.tpl new file mode 100644 index 0000000..6b6c00d --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/redis.conf.tpl @@ -0,0 +1,2 @@ +write_servers = "localhost"; +read_servers = "localhost"; diff --git a/modoboa_installer/scripts/files/rspamd/local.d/settings.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/settings.conf.tpl new file mode 100644 index 0000000..1eae1c0 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/settings.conf.tpl @@ -0,0 +1,8 @@ +authenticated { + priority = high; + authenticated = yes; + apply { + groups_disabled = ["rbl", "spf"]; + } +%{whitelist_auth_enabled} symbols ["WHITELIST_AUTHENTICATED"]; +} diff --git a/modoboa_installer/scripts/files/rspamd/local.d/spf.conf.tpl b/modoboa_installer/scripts/files/rspamd/local.d/spf.conf.tpl new file mode 100644 index 0000000..85a98bc --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/spf.conf.tpl @@ -0,0 +1,6 @@ +spf_cache_size = 1k; +spf_cache_expire = 1d; +max_dns_nesting = 10; +max_dns_requests = 30; +min_cache_ttl = 5m; +disable_ipv6 = false; diff --git a/modoboa_installer/scripts/files/rspamd/local.d/worker-controller.inc.tpl b/modoboa_installer/scripts/files/rspamd/local.d/worker-controller.inc.tpl new file mode 100644 index 0000000..8490a18 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/worker-controller.inc.tpl @@ -0,0 +1 @@ +enable_password = %controller_password diff --git a/modoboa_installer/scripts/files/rspamd/local.d/worker-normal.inc.tpl b/modoboa_installer/scripts/files/rspamd/local.d/worker-normal.inc.tpl new file mode 100644 index 0000000..a6ee831 --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/worker-normal.inc.tpl @@ -0,0 +1 @@ +enabled = false; diff --git a/modoboa_installer/scripts/files/rspamd/local.d/worker-proxy.inc.tpl b/modoboa_installer/scripts/files/rspamd/local.d/worker-proxy.inc.tpl new file mode 100644 index 0000000..f64333f --- /dev/null +++ b/modoboa_installer/scripts/files/rspamd/local.d/worker-proxy.inc.tpl @@ -0,0 +1,3 @@ +upstream "local" { + self_scan = yes; +} diff --git a/modoboa_installer/scripts/modoboa.py b/modoboa_installer/scripts/modoboa.py index c2d01bf..4d9b778 100644 --- a/modoboa_installer/scripts/modoboa.py +++ b/modoboa_installer/scripts/modoboa.py @@ -49,13 +49,7 @@ class Modoboa(base.Installer): self.instance_path = self.config.get("modoboa", "instance_path") self.extensions = self.config.get("modoboa", "extensions").split() self.devmode = self.config.getboolean("modoboa", "devmode") - # Sanity check for amavis - self.amavis_enabled = False - if "modoboa-amavis" in self.extensions: - if self.config.getboolean("amavis", "enabled"): - self.amavis_enabled = True - else: - self.extensions.remove("modoboa-amavis") + self.amavis_enabled = self.config.getboolean("amavis", "enabled") self.dovecot_enabled = self.config.getboolean("dovecot", "enabled") self.opendkim_enabled = self.config.getboolean("opendkim", "enabled") self.dkim_cron_enabled = False @@ -241,6 +235,7 @@ class Modoboa(base.Installer): "dovecot_mailboxes_owner": ( self.config.get("dovecot", "mailboxes_owner")), "opendkim_user": self.config.get("opendkim", "user"), + "dkim_user": "_rspamd" if self.config.getboolean("rspamd", "enabled") else self.config.get("opendkim", "user"), "minutes": random.randint(1, 59), "hours": f"{random_hour},{random_hour+12}", "modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#", @@ -284,6 +279,15 @@ class Modoboa(base.Installer): if self.config.getboolean("opendkim", "enabled"): settings["admin"]["dkim_keys_storage_dir"] = ( self.config.get("opendkim", "keys_storage_dir")) + + if self.config.getboolean("rspamd", "enabled"): + settings["admin"]["dkim_keys_storage_dir"] = ( + self.config.get("rspamd", "dkim_keys_storage_dir")) + settings["modoboa_rspamd"] = { + "key_map_path": self.config.get("rspamd", "key_map_path"), + "selector_map_path": self.config.get("rspamd", "selector_map_path") + } + settings = json.dumps(settings) query = ( "UPDATE core_localconfig SET _parameters='{}'" diff --git a/modoboa_installer/scripts/postfix.py b/modoboa_installer/scripts/postfix.py index 6a2d743..cf0c361 100644 --- a/modoboa_installer/scripts/postfix.py +++ b/modoboa_installer/scripts/postfix.py @@ -18,10 +18,9 @@ class Postfix(base.Installer): appname = "postfix" packages = { - "deb": ["postfix"], - "rpm": ["postfix"], + "deb": ["postfix", "postfix-pcre"], } - config_files = ["main.cf", "master.cf"] + config_files = ["main.cf", "master.cf", "anonymize_headers.pcre"] def get_packages(self): """Additional packages.""" @@ -60,7 +59,9 @@ class Postfix(base.Installer): "modoboa_instance_path": self.config.get( "modoboa", "instance_path"), "opendkim_port": self.config.get( - "opendkim", "port") + "opendkim", "port"), + "rspamd_disabled": "" if not self.config.getboolean( + "rspamd", "enabled") else "#" }) return context @@ -101,8 +102,18 @@ class Postfix(base.Installer): utils.exec_cmd("postalias {}".format(aliases_file)) # Postwhite - install("postwhite", self.config, self.upgrade, self.archive_path) + condition = ( + not self.config.getboolean("rspamd", "enabled") and + self.config.getboolean("postwhite", "enabled") + ) + if condition: + install("postwhite", self.config, self.upgrade, self.archive_path) def backup(self, path): """Launch postwhite backup.""" - backup("postwhite", self.config, path) + condition = ( + not self.config.getboolean("rspamd", "enabled") and + self.config.getboolean("postwhite", "enabled") + ) + if condition: + backup("postwhite", self.config, path) diff --git a/modoboa_installer/scripts/rspamd.py b/modoboa_installer/scripts/rspamd.py new file mode 100644 index 0000000..c45681c --- /dev/null +++ b/modoboa_installer/scripts/rspamd.py @@ -0,0 +1,154 @@ +"""Rspamd related functions.""" + +import os +import pwd +import stat + +from .. import package +from .. import utils +from .. import system + +from . import base +from . import install + + +class Rspamd(base.Installer): + """Rspamd installer.""" + + appname = "rspamd" + packages = { + "deb": [ + "rspamd", "redis" + ] + } + config_files = [ + "local.d/arc.conf", + "local.d/dkim_signing.conf", + "local.d/dmarc.conf", + "local.d/force_actions.conf", + "local.d/greylist.conf", + "local.d/metrics.conf", + "local.d/milter_headers.conf", + "local.d/mx_check.conf", + "local.d/redis.conf", + "local.d/settings.conf", + "local.d/spf.conf", + "local.d/worker-normal.inc", + "local.d/worker-proxy.inc", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.generate_password_condition = ( + not self.upgrade or utils.user_input( + "Do you want to (re)generate rspamd password ? (y/N)").lower().startswith("y") + ) + + @property + def config_dir(self): + """Return appropriate config dir.""" + return "/etc/rspamd" + + def install_packages(self): + debian_based_dist, codename = utils.is_dist_debian_based() + if debian_based_dist: + utils.mkdir_safe( + "/etc/apt/keyrings", + stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH, + 0, 0 + ) + + package.backend.add_custom_repository( + "rspamd", + "http://rspamd.com/apt-stable/", + "https://rspamd.com/apt-stable/gpg.key", + codename + ) + package.backend.update() + + return super().install_packages() + + def install_config_files(self): + """Make sure config directory exists.""" + user = self.config.get(self.appname, "user") + pw = pwd.getpwnam(user) + targets = [ + [self.app_config["dkim_keys_storage_dir"], pw[2], pw[3]] + ] + for target in targets: + if not os.path.exists(target[0]): + utils.mkdir( + target[0], + stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH, + target[1], target[2] + ) + super().install_config_files() + + def get_config_files(self): + """Return appropriate config files.""" + _config_files = self.config_files + if self.config.getboolean("clamav", "enabled"): + _config_files.append("local.d/antivirus.conf") + if self.app_config["dnsbl"].lower() == "true": + _config_files.append("local.d/rbl.conf") + if self.app_config["whitelist_auth"].lower() == "true": + _config_files.append("local.d/groups.conf") + if self.generate_password_condition: + _config_files.append("local.d/worker-controller.inc") + return _config_files + + def get_template_context(self): + _context = super().get_template_context() + _context["greylisting_disabled"] = "" if not self.app_config["greylisting"].lower() == "true" else "#" + _context["whitelist_auth_enabled"] = "" if self.app_config["whitelist_auth"].lower() == "true" else "#" + if self.generate_password_condition: + code, controller_password = utils.exec_cmd( + r"rspamadm pw -p {}".format(self.app_config["password"])) + if code != 0: + utils.error("Error setting rspamd password. " + "Please make sure it is not 'q1' or 'q2'." + "Storing the password in plain. See" + "https://rspamd.com/doc/quickstart.html#setting-the-controller-password") + _context["controller_password"] = self.app_config["password"] + else: + controller_password = controller_password.decode().replace("\n", "") + _context["controller_password"] = controller_password + return _context + + def post_run(self): + """Additional tasks.""" + user = self.config.get(self.appname, "user") + system.add_user_to_group( + self.config.get("modoboa", "user"), + user + ) + if self.config.getboolean("clamav", "enabled"): + install("clamav", self.config, self.upgrade, self.archive_path) + + def custom_backup(self, path): + """Backup custom configuration if any.""" + custom_config_dir = os.path.join(self.config_dir, + "/local.d/") + custom_backup_dir = os.path.join(path, "/rspamd/") + local_files = [f for f in os.listdir(custom_config_dir) + if os.path.isfile(custom_config_dir, f) + ] + for file in local_files: + utils.copy_file(file, custom_backup_dir) + if len(local_files) != 0: + utils.success("Rspamd custom configuration saved!") + + def restore(self): + """Restore custom config files.""" + custom_config_dir = os.path.join(self.config_dir, + "/local.d/") + custom_backup_dir = os.path.join(self.archive_path, "/rspamd/") + backed_up_files = [ + f for f in os.listdir(custom_backup_dir) + if os.path.isfile(custom_backup_dir, f) + ] + for f in backed_up_files: + utils.copy_file(f, custom_config_dir) + utils.success("Custom Rspamd configuration restored.") diff --git a/modoboa_installer/utils.py b/modoboa_installer/utils.py index a9cd5f4..372da1d 100644 --- a/modoboa_installer/utils.py +++ b/modoboa_installer/utils.py @@ -1,5 +1,6 @@ """Utility functions.""" +import configparser import contextlib import datetime import getpass @@ -13,12 +14,9 @@ import string import subprocess import sys import uuid -try: - import configparser -except ImportError: - import ConfigParser as configparser from . import config_dict_template +from .compatibility_matrix import APP_INCOMPATIBILITY ENV = {} @@ -34,12 +32,7 @@ class FatalError(Exception): def user_input(message): """Ask something to the user.""" - try: - from builtins import input - except ImportError: - answer = raw_input(message) - else: - answer = input(message) + answer = input(message) return answer @@ -102,6 +95,17 @@ def dist_name(): return dist_info()[0].lower() +def is_dist_debian_based() -> (bool, str): + """Check if current OS is Debian based or not.""" + status, codename = exec_cmd("lsb_release -c -s") + codename = codename.decode().strip().lower() + return codename in [ + "bionic", "bookworm", "bullseye", "buster", + "focal", "jammy", "jessie", "sid", "stretch", + "trusty", "wheezy", "xenial" + ], codename + + def mkdir(path, mode, uid, gid): """Create a directory.""" if not os.path.exists(path): @@ -173,25 +177,29 @@ def copy_from_template(template, dest, context): fp.write(ConfigFileTemplate(buf).substitute(context)) -def check_config_file(dest, interactive=False, upgrade=False, backup=False, restore=False): +def check_config_file(dest, + interactive=False, + upgrade=False, + backup=False, + restore=False): """Create a new installer config file if needed.""" is_present = True if os.path.exists(dest): return is_present, update_config(dest, False) if upgrade: - printcolor( + error( "You cannot upgrade an existing installation without a " - "configuration file.", RED) + "configuration file.") sys.exit(1) elif backup: is_present = False - printcolor( + error( "Your configuration file hasn't been found. A new one will be generated. " - "Please edit it with correct password for the databases !", RED) + "Please edit it with correct password for the databases !") elif restore: - printcolor( + error( "You cannot restore an existing installation without a " - f"configuration file. (file : {dest} has not been found...", RED) + f"configuration file. (file : {dest} has not been found...") sys.exit(1) printcolor( @@ -277,6 +285,16 @@ def random_key(l=16): return key +def check_if_condition(config, entry): + """Check if the "if" directive is present and computes it""" + section_if = True + for condition in entry: + config_key, value = condition.split("=") + section_name, option = config_key.split(".") + section_if = config.get(section_name, option) == value + return section_if + + def validate(value, config_entry): if value is None: return False @@ -297,11 +315,14 @@ def validate(value, config_entry): return True -def get_entry_value(entry, interactive): - if callable(entry["default"]): +def get_entry_value(entry: dict, interactive: bool, config: configparser.ConfigParser) -> string: + default_entry = entry["default"] + if type(default_entry) is type(list()): + default_value = str(check_if_condition(config, default_entry)).lower() + elif callable(default_entry): default_value = entry["default"]() else: - default_value = entry["default"] + default_value = default_entry user_value = None if entry.get("customizable") and interactive: while (user_value != '' and not validate(user_value, entry)): @@ -337,16 +358,22 @@ def load_config_template(interactive): config = configparser.ConfigParser() # only ask about options we need, else still generate default for section in tpl_dict: + interactive_section = interactive if "if" in section: - config_key, value = section.get("if").split("=") - section_name, option = config_key.split(".") - interactive_section = ( - config.get(section_name, option) == value and interactive) - else: - interactive_section = interactive + condition = check_if_condition(config, section["if"]) + interactive_section = condition and interactive + config.add_section(section["name"]) for config_entry in section["values"]: - value = get_entry_value(config_entry, interactive_section) + if config_entry.get("if") is not None: + interactive_section = (interactive_section and + check_if_condition( + config, config_entry["if"] + ) + ) + value = get_entry_value(config_entry, + interactive_section, + config) config.set(section["name"], config_entry["option"], value) return config @@ -446,7 +473,7 @@ def validate_backup_path(path: str, silent_mode: bool): if not path_exists: if not silent_mode: create_dir = input( - f"\"{path}\" doesn't exist, would you like to create it? [Y/n]\n" + f"\"{path}\" doesn't exist, would you like to create it? [y/N]\n" ).lower() if silent_mode or (not silent_mode and create_dir.startswith("y")): @@ -461,7 +488,7 @@ def validate_backup_path(path: str, silent_mode: bool): if len(os.listdir(path)) != 0: if not silent_mode: delete_dir = input( - "Warning: backup directory is not empty, it will be purged if you continue... [Y/n]\n").lower() + "Warning: backup directory is not empty, it will be purged if you continue... [y/N]\n").lower() if silent_mode or (not silent_mode and delete_dir.startswith("y")): try: @@ -504,3 +531,15 @@ def create_oauth2_app(app_name: str, client_id: str, config) -> tuple[str, str]: ) exec_cmd(cmd) return client_id, client_secret + + +def check_app_compatibility(section, config): + """Check that the app can be installed in regards to other enabled apps.""" + incompatible_app = [] + if section in APP_INCOMPATIBILITY.keys(): + for app in APP_INCOMPATIBILITY[section]: + if config.getboolean(app, "enabled"): + error(f"{section} cannot be installed if {app} is enabled. " + "Please disable one of them.") + incompatible_app.append(app) + return len(incompatible_app) == 0 diff --git a/run.py b/run.py index 22cfa14..83027d8 100755 --- a/run.py +++ b/run.py @@ -3,15 +3,12 @@ """An installer for Modoboa.""" import argparse +import configparser import datetime import os -try: - import configparser -except ImportError: - import ConfigParser as configparser import sys -import checks +from modoboa_installer import checks from modoboa_installer import compatibility_matrix from modoboa_installer import constants from modoboa_installer import package @@ -19,75 +16,24 @@ from modoboa_installer import scripts from modoboa_installer import ssl from modoboa_installer import system from modoboa_installer import utils +from modoboa_installer import disclaimers PRIMARY_APPS = [ - "amavis", "fail2ban", "modoboa", "automx", "radicale", "uwsgi", "nginx", - "opendkim", "postfix", "dovecot" ] -def installation_disclaimer(args, config): - """Display installation disclaimer.""" - hostname = config.get("general", "hostname") - utils.printcolor( - "Notice:\n" - "It is recommanded to run this installer on a FRESHLY installed server.\n" - "(ie. with nothing special already installed on it)\n", - utils.CYAN - ) - utils.printcolor( - "Warning:\n" - "Before you start the installation, please make sure the following " - "DNS records exist for domain '{}':\n" - " {} IN A \n" - " @ IN MX {}.\n".format( - args.domain, - hostname.replace(".{}".format(args.domain), ""), - hostname - ), - utils.YELLOW - ) - utils.printcolor( - "Your mail server will be installed with the following components:", - utils.BLUE) - - -def upgrade_disclaimer(config): - """Display upgrade disclaimer.""" - utils.printcolor( - "Your mail server is about to be upgraded and the following components" - " will be impacted:", utils.BLUE - ) - - -def backup_disclaimer(): - """Display backup disclamer. """ - utils.printcolor( - "Your mail server will be backed up locally.\n" - " !! You should really transfer the backup somewhere else...\n" - " !! Custom configuration (like for postfix) won't be saved.", utils.BLUE) - - -def restore_disclaimer(): - """Display restore disclamer. """ - utils.printcolor( - "You are about to restore a previous installation of Modoboa.\n" - "If a new version has been released in between, please update your database!", - utils.BLUE) - - def backup_system(config, args): """Launch backup procedure.""" - backup_disclaimer() + disclaimers.backup_disclaimer() backup_path = None if args.silent_backup: if not args.backup_path: @@ -135,12 +81,11 @@ def config_file_update_complete(backup_location): utils.BLUE) -def main(input_args): - """Install process.""" +def parser_setup(input_args): parser = argparse.ArgumentParser() versions = ( ["latest"] + list(compatibility_matrix.COMPATIBILITY_MATRIX.keys()) - ) + ) parser.add_argument("--debug", action="store_true", default=False, help="Enable debug output") parser.add_argument("--force", action="store_true", default=False, @@ -168,7 +113,7 @@ def main(input_args): parser.add_argument( "--backup", action="store_true", default=False, help="Backing up interactively previously installed instance" - ) + ) parser.add_argument( "--silent-backup", action="store_true", default=False, help="For script usage, do not require user interaction " @@ -181,13 +126,18 @@ def main(input_args): "--restore", type=str, metavar="path", help="Restore a previously backup up modoboa instance on a NEW machine. " "You MUST provide backup directory" - ), + ) parser.add_argument( "--skip-checks", action="store_true", default=False, help="Skip the checks the installer performs initially") parser.add_argument("domain", type=str, help="The main domain of your future mail server") - args = parser.parse_args(input_args) + return parser.parse_args(input_args) + + +def main(input_args): + """Install process.""" + args = parser_setup(input_args) if args.debug: utils.ENV["debug"] = True @@ -246,28 +196,36 @@ def main(input_args): config.set("modoboa", "version", args.version) config.set("modoboa", "install_beta", str(args.beta)) + if config.get("antispam", "type") == "amavis": + antispam_apps = ["amavis", "opendkim"] + else: + antispam_apps = ["rspamd"] + if args.backup or args.silent_backup: backup_system(config, args) return # Display disclaimer python 3 linux distribution if args.upgrade: - upgrade_disclaimer(config) + disclaimers.upgrade_disclaimer(config) elif args.restore: - restore_disclaimer() + disclaimers.restore_disclaimer() scripts.restore_prep(args.restore) else: - installation_disclaimer(args, config) + disclaimers.installation_disclaimer(args, config) # Show concerned components components = [] for section in config.sections(): - if section in ["general", "database", "mysql", "postgres", + if section in ["general", "antispam", "database", "mysql", "postgres", "certificate", "letsencrypt", "backup"]: continue if (config.has_option(section, "enabled") and not config.getboolean(section, "enabled")): continue + incompatible_app_detected = not utils.check_app_compatibility(section, config) + if incompatible_app_detected: + sys.exit(0) components.append(section) utils.printcolor(" ".join(components), utils.YELLOW) if not args.force: @@ -284,19 +242,26 @@ def main(input_args): ssl_backend = ssl.get_backend(config) if ssl_backend and not args.upgrade: ssl_backend.generate_cert() - for appname in PRIMARY_APPS: + for appname in PRIMARY_APPS + antispam_apps: scripts.install(appname, config, args.upgrade, args.restore) system.restart_service("cron") package.backend.restore_system() + hostname = config.get("general", "hostname") if not args.restore: utils.success( - "Congratulations! You can enjoy Modoboa at https://{} (admin:password)" - .format(config.get("general", "hostname")) + f"Congratulations! You can enjoy Modoboa at https://{hostname} " + "(admin:password)" ) else: utils.success( - "Restore complete! You can enjoy Modoboa at https://{} (same credentials as before)" - .format(config.get("general", "hostname")) + f"Restore complete! You can enjoy Modoboa at https://{hostname} " + "(same credentials as before)" + ) + if config.getboolean("rspamd", "enabled"): + rspamd_password = config.get("rspamd", "password") + utils.success( + f"You can also enjoy rspamd at https://{hostname}/rspamd " + f"(password: {rspamd_password})" ) utils.success( "\n" diff --git a/tests.py b/tests.py index 6f8fdf6..80929fb 100644 --- a/tests.py +++ b/tests.py @@ -47,7 +47,7 @@ class ConfigFileTestCase(unittest.TestCase): def test_interactive_mode(self, mock_user_input): """Check interactive mode.""" mock_user_input.side_effect = [ - "0", "0", "", "", "", "", "" + "0", "0", "", "", "", "", "", "" ] with open(os.devnull, "w") as fp: sys.stdout = fp @@ -99,7 +99,7 @@ class ConfigFileTestCase(unittest.TestCase): def test_interactive_mode_letsencrypt(self, mock_user_input): """Check interactive mode.""" mock_user_input.side_effect = [ - "1", "admin@example.test", "0", "", "", "", "", "" + "0", "0", "1", "admin@example.test", "0", "", "", "", "" ] with open(os.devnull, "w") as fp: sys.stdout = fp @@ -126,12 +126,13 @@ class ConfigFileTestCase(unittest.TestCase): "example.test"]) self.assertTrue(os.path.exists(self.cfgfile)) self.assertIn( - "modoboa automx amavis clamav dovecot nginx razor postfix" - " postwhite spamassassin uwsgi", + "fail2ban modoboa automx amavis clamav dovecot nginx " + "postfix postwhite spamassassin uwsgi radicale opendkim", out.getvalue() ) - self.assertNotIn("It seems that your config file is outdated.", - out.getvalue() + self.assertNotIn( + "It seems that your config file is outdated.", + out.getvalue() ) @patch("modoboa_installer.utils.user_input")