5 Commits

Author SHA1 Message Date
Spitap
4e2e9b6ab9 Revert "Make pip quiet"
This reverts commit 7ccc871da7.
2024-02-05 12:27:17 +01:00
Spitap
7ccc871da7 Make pip quiet 2024-02-05 12:23:06 +01:00
Spitap
5f4817736f Increased verbosity 2024-02-05 11:52:26 +01:00
Spitfireap
804c20a18d fixed output not decoded 2024-02-01 13:13:06 +01:00
Spitfireap
dd32b21ce9 Improved version checking 2024-02-01 13:07:11 +01:00
56 changed files with 349 additions and 1130 deletions

View File

@@ -11,48 +11,47 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9, '3.10', '3.11', '3.12'] python-version: [3.7, 3.8, 3.9]
fail-fast: false fail-fast: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -r test-requirements.txt pip install -r test-requirements.txt
- name: Run tests - name: Run tests
if: ${{ matrix.python-version != '3.12' }} if: ${{ matrix.python-version != '3.9' }}
run: | run: |
python tests.py python tests.py
- name: Run tests and coverage - name: Run tests and coverage
if: ${{ matrix.python-version == '3.12' }} if: ${{ matrix.python-version == '3.9' }}
run: | run: |
coverage run tests.py coverage run tests.py
- name: Upload coverage result - name: Upload coverage result
if: ${{ matrix.python-version == '3.12' }} if: ${{ matrix.python-version == '3.9' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v2
with: with:
name: coverage-results name: coverage-results
path: .coverage path: .coverage
include-hidden-files: true
coverage: coverage:
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v2
with: with:
python-version: '3.12' python-version: '3.9'
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install codecov pip install codecov
- name: Download coverage results - name: Download coverage results
uses: actions/download-artifact@v4 uses: actions/download-artifact@v2
with: with:
name: coverage-results name: coverage-results
- name: Report coverage - name: Report coverage

View File

@@ -1,32 +0,0 @@
name: Update version file
on:
workflow_run:
branches: [ master ]
workflows: [Modoboa installer]
types:
- completed
jobs:
update-version:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository.
ref: ${{ github.head_ref }}
- name: Overwrite file
uses: "DamianReeves/write-file-action@master"
with:
path: version.txt
write-mode: overwrite
contents: ${{ github.sha }}
- name: Commit & Push
uses: Andro999b/push@v1.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref_name }}
force: true
message: '[GitHub Action] Updated version file'

5
.gitignore vendored
View File

@@ -58,8 +58,3 @@ target/
# PyCharm # PyCharm
.idea/ .idea/
#KDE
*.kdev4
installer.cfg

View File

@@ -1,5 +1,5 @@
**modoboa-installer** modoboa-installer
===================== =================
|workflow| |codecov| |workflow| |codecov|
@@ -9,8 +9,9 @@ An installer which deploy a complete mail server based on Modoboa.
This tool is still in beta stage, it has been tested on: This tool is still in beta stage, it has been tested on:
* Debian 12 and upper * Debian Buster (10) / Bullseye (11)
* Ubuntu Focal Fossa (20.04) and upper * Ubuntu Bionic Beaver (18.04) and upper
* CentOS 7
.. warning:: .. warning::
@@ -43,7 +44,7 @@ The following components are installed by the installer:
* Nginx and uWSGI * Nginx and uWSGI
* Postfix * Postfix
* Dovecot * Dovecot
* Amavis (with SpamAssassin and ClamAV) or Rspamd * Amavis (with SpamAssassin and ClamAV)
* automx (autoconfiguration service) * automx (autoconfiguration service)
* OpenDKIM * OpenDKIM
* Radicale (CalDAV and CardDAV server) * Radicale (CalDAV and CardDAV server)
@@ -76,7 +77,7 @@ If you want more information about the installation process, add the
``--debug`` option to your command line. ``--debug`` option to your command line.
Upgrade mode Upgrade mode
============ ------------
An experimental upgrade mode is available. An experimental upgrade mode is available.
@@ -92,7 +93,7 @@ You can activate it as follows::
It will automatically install latest versions of modoboa and its plugins. It will automatically install latest versions of modoboa and its plugins.
Backup mode Backup mode
=========== ------------
An experimental backup mode is available. An experimental backup mode is available.
@@ -107,19 +108,7 @@ You can start the process as follows::
Then follow the step on the console. Then follow the step on the console.
There is also a non-interactive mode:: There is also a non-interactive mode:
$ sudo ./run.py --silent-backup <your domain>
You can also add a path, else it will be saved in ./modoboa_backup/Backup_M_Y_d_H_M::
$ sudo ./run.py --silent-backup --backup-path "/My_Backup_Path" <your domain>
if you want to disable mail backup::
$ sudo ./run.py --backup --no-mail <your domain>
This can be useful for larger instance
1. Silent mode 1. Silent mode
@@ -141,7 +130,7 @@ configuration file (set enabled to False).
This can be useful for larger instance. This can be useful for larger instance.
Restore mode Restore mode
============ ------------
An experimental restore mode is available. An experimental restore mode is available.
@@ -152,7 +141,7 @@ You can start the process as follows::
Then wait for the process to finish. Then wait for the process to finish.
Change the generated hostname Change the generated hostname
============================= -----------------------------
By default, the installer will setup your email server using the By default, the installer will setup your email server using the
following hostname: ``mail.<your domain>``. If you want a different following hostname: ``mail.<your domain>``. If you want a different
@@ -171,26 +160,14 @@ modifications.
Finally, run the installer without the Finally, run the installer without the
``--stop-after-configfile-check`` option. ``--stop-after-configfile-check`` option.
Certificate Let's Encrypt certificate
=========== -------------------------
Self-signed
-----------
It is the default type of certificate the installer will generate, it
is however not recommended for production use.
Letsencrypt
-----------
.. warning:: .. warning::
Please note that by using this option, you agree to the `ToS Please note this option requires the hostname you're using to be
<https://community.letsencrypt.org/tos>`_ of valid (ie. it can be resolved with a DNS query) and to match the
letsencrypt and that your IP will be logged (see ToS). server you're installing Modoboa on.
Please also note this option requires the hostname you're using to be
valid (ie. it can be resolved with a DNS query) and to match the
server you're installing Modoboa on.
If you want to generate a valid certificate using `Let's Encrypt If you want to generate a valid certificate using `Let's Encrypt
<https://letsencrypt.org/>`_, edit the ``installer.cfg`` file and <https://letsencrypt.org/>`_, edit the ``installer.cfg`` file and
@@ -199,8 +176,6 @@ modify the following settings::
[certificate] [certificate]
generate = true generate = true
type = letsencrypt type = letsencrypt
tls_cert_file_path =
tls_key_file_path =
[letsencrypt] [letsencrypt]
email = admin@example.com email = admin@example.com
@@ -208,43 +183,6 @@ modify the following settings::
Change the ``email`` setting to a valid value since it will be used Change the ``email`` setting to a valid value since it will be used
for account recovery. for account recovery.
Manual
------
.. warning::
It is not possible to configure manual certs interactively, so
you'll have to do it in 2 steps. Please run ``run.py`` with
`--stop-after-configfile-check` first, configure your file as
desired and apply the configuration as written bellow. Then run
``run.py`` again but without `--stop-after-configfile-check` or
`--interactive`.
If you want to use already generated certs, simply edit the
``installer.cfg`` file and modify the following settings::
[certificate]
generate = true
type = manual
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 .. |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 .. |codecov| image:: http://codecov.io/github/modoboa/modoboa-installer/coverage.svg?branch=master
:target: https://codecov.io/gh/modoboa/modoboa-installer :target: http://codecov.io/github/modoboa/modoboa-installer?branch=master

View File

@@ -1,37 +0,0 @@
"""Checks to be performed before any install or upgrade"""
import sys
from urllib.request import urlopen
from modoboa_installer import utils
def check_version():
local_version = ""
with open("version.txt", "r") as version:
local_version = version.readline()
remote_version = ""
with urlopen("https://raw.githubusercontent.com/modoboa/modoboa-installer/master/version.txt") as r_version:
remote_version = r_version.read().decode()
if local_version == "" or remote_version == "":
utils.printcolor(
"Could not check that your installer is up-to-date: "
f"local version: {local_version}, "
f"remote version: {remote_version}",
utils.YELLOW
)
if remote_version != local_version:
utils.error(
"Your installer seems outdated.\n"
"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) ")
if not answer.lower().startswith("y"):
sys.exit(0)
else:
utils.success("Installer seems up to date!")
def handle():
check_version()

View File

@@ -21,26 +21,13 @@ COMPATIBILITY_MATRIX = {
"modoboa-sievefilters": ">=1.1.1", "modoboa-sievefilters": ">=1.1.1",
"modoboa-webmail": ">=1.2.0", "modoboa-webmail": ">=1.2.0",
}, },
"2.1.0": {
"modoboa-pdfcredentials": None,
"modoboa-dmarc": None,
"modoboa-imap-migration": None,
},
} }
EXTENSIONS_AVAILABILITY = { EXTENSIONS_AVAILABILITY = {
"modoboa-contacts": "1.7.4", "modoboa-contacts": "1.7.4",
} }
REMOVED_EXTENSIONS = {
"modoboa-pdfcredentials": "2.1.0",
"modoboa-dmarc": "2.1.0",
"modoboa-imap-migration": "2.1.0",
"modoboa-sievefilters": "2.3.0",
"modoboa-postfix-autoreply": "2.3.0",
"modoboa-contacts": "2.4.0",
"modoboa-radicale": "2.4.0",
"modoboa-webmail": "2.4.0",
}
APP_INCOMPATIBILITY = {
"opendkim": ["rspamd"],
"amavis": ["rspamd"],
"postwhite": ["rspamd"],
"spamassassin": ["rspamd"]
}

View File

@@ -27,50 +27,25 @@ 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", "name": "certificate",
"values": [ "values": [
{
"option": "generate",
"default": "true",
},
{ {
"option": "type", "option": "type",
"default": "self-signed", "default": "self-signed",
"customizable": True, "customizable": True,
"question": "Please choose your certificate type", "question": "Please choose your certificate type",
"values": ["self-signed", "letsencrypt", "manual"], "values": ["self-signed", "letsencrypt"],
"non_interactive_values": ["manual"],
},
{
"option": "tls_cert_file_path",
"default": ""
},
{
"option": "tls_key_file_path",
"default": ""
} }
], ],
}, },
{ {
"name": "letsencrypt", "name": "letsencrypt",
"if": ["certificate.type=letsencrypt"], "if": "certificate.type=letsencrypt",
"values": [ "values": [
{ {
"option": "email", "option": "email",
@@ -105,7 +80,7 @@ ConfigDictTemplate = [
}, },
{ {
"name": "postgres", "name": "postgres",
"if": ["database.engine=postgres"], "if": "database.engine=postgres",
"values": [ "values": [
{ {
"option": "user", "option": "user",
@@ -121,7 +96,7 @@ ConfigDictTemplate = [
}, },
{ {
"name": "mysql", "name": "mysql",
"if": ["database.engine=mysql"], "if": "database.engine=mysql",
"values": [ "values": [
{ {
"option": "user", "option": "user",
@@ -205,16 +180,14 @@ ConfigDictTemplate = [
"customizable": True, "customizable": True,
"question": "Please enter Modoboa db password", "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", "option": "extensions",
"default": "" "default": (
"modoboa-amavis "
"modoboa-postfix-autoreply modoboa-sievefilters "
"modoboa-webmail modoboa-contacts "
"modoboa-radicale"
),
}, },
{ {
"option": "devmode", "option": "devmode",
@@ -251,60 +224,12 @@ 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", "name": "amavis",
"values": [ "values": [
{ {
"option": "enabled", "option": "enabled",
"default": ["antispam.enabled=true", "antispam.type=amavis"], "default": "true",
}, },
{ {
"option": "user", "option": "user",
@@ -325,6 +250,8 @@ ConfigDictTemplate = [
{ {
"option": "dbpassword", "option": "dbpassword",
"default": make_password, "default": make_password,
"customizable": True,
"question": "Please enter amavis db password"
}, },
], ],
}, },
@@ -374,11 +301,7 @@ ConfigDictTemplate = [
}, },
{ {
"option": "radicale_auth_socket_path", "option": "radicale_auth_socket_path",
"default": "/var/run/dovecot/auth-radicale", "default": "/var/run/dovecot/auth-radicale"
},
{
"option": "move_spam_to_junk",
"default": "true",
}, },
] ]
}, },
@@ -400,7 +323,7 @@ ConfigDictTemplate = [
"values": [ "values": [
{ {
"option": "enabled", "option": "enabled",
"default": "false", "default": "true",
}, },
{ {
"option": "config_dir", "option": "config_dir",
@@ -434,7 +357,7 @@ ConfigDictTemplate = [
"values": [ "values": [
{ {
"option": "enabled", "option": "enabled",
"default": ["antispam.enabled=true", "antispam.type=amavis"], "default": "true",
}, },
{ {
"option": "config_dir", "option": "config_dir",
@@ -444,11 +367,10 @@ ConfigDictTemplate = [
}, },
{ {
"name": "spamassassin", "name": "spamassassin",
"if": ["antispam.enabled=true", "antispam.type=amavis"],
"values": [ "values": [
{ {
"option": "enabled", "option": "enabled",
"default": ["antispam.enabled=true", "antispam.type=amavis"], "default": "true",
}, },
{ {
"option": "config_dir", "option": "config_dir",
@@ -483,7 +405,7 @@ ConfigDictTemplate = [
}, },
{ {
"option": "nb_processes", "option": "nb_processes",
"default": "4", "default": "2",
}, },
] ]
}, },
@@ -514,11 +436,10 @@ ConfigDictTemplate = [
}, },
{ {
"name": "opendkim", "name": "opendkim",
"if": ["antispam.enabled=true", "antispam.type=amavis"],
"values": [ "values": [
{ {
"option": "enabled", "option": "enabled",
"default": ["antispam.enabled=true", "antispam.type=amavis"], "default": "true",
}, },
{ {
"option": "user", "option": "user",

View File

@@ -103,7 +103,7 @@ class PostgreSQL(Database):
def create_database(self, name, owner): def create_database(self, name, owner):
"""Create a database.""" """Create a database."""
code, output = utils.exec_cmd( code, output = utils.exec_cmd(
"psql -lqt | cut -d \\| -f 1 | grep -w {} | wc -l" "psql -lqt | cut -d \| -f 1 | grep -w {} | wc -l"
.format(name), sudo_user=self.dbuser) .format(name), sudo_user=self.dbuser)
if code: if code:
return return
@@ -184,7 +184,7 @@ class MySQL(Database):
self.packages["deb"].append("libmariadbclient-dev") self.packages["deb"].append("libmariadbclient-dev")
elif name == "ubuntu": elif name == "ubuntu":
if version.startswith("2"): if version.startswith("2"):
# Works for Ubuntu 20, 22, and 24. # Works for Ubuntu 22 and 20
self.packages["deb"].append("libmariadb-dev") self.packages["deb"].append("libmariadb-dev")
else: else:
self.packages["deb"].append("libmysqlclient-dev") self.packages["deb"].append("libmysqlclient-dev")
@@ -201,7 +201,7 @@ class MySQL(Database):
return return
if ( if (
(name.startswith("debian") and (version.startswith("11") or version.startswith("12"))) or (name.startswith("debian") and (version.startswith("11") or version.startswith("12"))) or
(name.startswith("ubuntu") and int(version[:2]) >= 22) (name.startswith("ubuntu") and version.startswith("22"))
): ):
queries = [ queries = [
"SET PASSWORD FOR 'root'@'localhost' = PASSWORD('{}')" "SET PASSWORD FOR 'root'@'localhost' = PASSWORD('{}')"

View File

@@ -1,51 +0,0 @@
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 <IP ADDRESS OF YOUR SERVER>\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)

View File

@@ -2,12 +2,10 @@
import re import re
from os.path import isfile as file_exists
from . import utils from . import utils
class Package: class Package(object):
"""Base classe.""" """Base classe."""
def __init__(self, dist_name): def __init__(self, dist_name):
@@ -31,17 +29,10 @@ class DEBPackage(Package):
FORMAT = "deb" FORMAT = "deb"
def __init__(self, dist_name): def __init__(self, dist_name):
super().__init__(dist_name) super(DEBPackage, self).__init__(dist_name)
self.index_updated = False self.index_updated = False
self.policy_file = "/usr/sbin/policy-rc.d" self.policy_file = "/usr/sbin/policy-rc.d"
def enable_backports(self, codename):
code, output = utils.exec_cmd(f"grep {codename}-backports /etc/apt/sources.list")
if code:
with open(f"/etc/apt/sources.list.d/backports.list", "w") as fp:
fp.write(f"deb http://deb.debian.org/debian {codename}-backports main\n")
self.update(force=True)
def prepare_system(self): def prepare_system(self):
"""Make sure services don't start at installation.""" """Make sure services don't start at installation."""
with open(self.policy_file, "w") as fp: with open(self.policy_file, "w") as fp:
@@ -51,32 +42,9 @@ class DEBPackage(Package):
def restore_system(self): def restore_system(self):
utils.exec_cmd("rm -f {}".format(self.policy_file)) utils.exec_cmd("rm -f {}".format(self.policy_file))
def add_custom_repository(self, def update(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.""" """Update local cache."""
if self.index_updated and not force: if self.index_updated:
return return
utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 update --quiet") utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 update --quiet")
self.index_updated = True self.index_updated = True
@@ -89,12 +57,12 @@ class DEBPackage(Package):
def install(self, name): def install(self, name):
"""Install a package.""" """Install a package."""
self.update() self.update()
utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 install --quiet --assume-yes -o DPkg::options::=--force-confold {}".format(name)) utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 install --quiet --assume-yes {}".format(name))
def install_many(self, names): def install_many(self, names):
"""Install many packages.""" """Install many packages."""
self.update() self.update()
return utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 install --quiet --assume-yes -o DPkg::options::=--force-confold {}".format( return utils.exec_cmd("apt-get -o Dpkg::Progress-Fancy=0 install --quiet --assume-yes {}".format(
" ".join(names))) " ".join(names)))
def get_installed_version(self, name): def get_installed_version(self, name):
@@ -114,7 +82,7 @@ class RPMPackage(Package):
def __init__(self, dist_name): def __init__(self, dist_name):
"""Initialize backend.""" """Initialize backend."""
super().__init__(dist_name) super(RPMPackage, self).__init__(dist_name)
if "centos" in dist_name: if "centos" in dist_name:
self.install("epel-release") self.install("epel-release")
@@ -140,7 +108,7 @@ def get_backend():
"""Return the appropriate package backend.""" """Return the appropriate package backend."""
distname = utils.dist_name() distname = utils.dist_name()
backend = None backend = None
if distname in ["debian", "debian gnu/linux", "ubuntu", "linuxmint"]: if distname in ["debian", "debian gnu/linux", "ubuntu"]:
backend = DEBPackage backend = DEBPackage
elif "centos" in distname: elif "centos" in distname:
backend = RPMPackage backend = RPMPackage

View File

@@ -1,5 +1,6 @@
"""Python related tools.""" """Python related tools."""
import json
import os import os
import sys import sys
@@ -48,35 +49,29 @@ def install_packages(names, venv=None, upgrade=False, **kwargs):
def get_package_version(name, venv=None, **kwargs): def get_package_version(name, venv=None, **kwargs):
"""Returns the version of an installed package.""" """Returns the version of an installed package."""
cmd = "{} show {}".format( cmd = f"{get_pip_path(venv)} list --format json"
get_pip_path(venv),
name
)
exit_code, output = utils.exec_cmd(cmd, **kwargs) exit_code, output = utils.exec_cmd(cmd, **kwargs)
if exit_code != 0: if exit_code != 0:
utils.error(f"Failed to get version of {name}. " utils.error(f"Failed to get version of {name}. "
f"Output is: {output}") f"Output is: {output}")
sys.exit(1) sys.exit(1)
print(f"name: {name}, venv: {venv}, cmd: {cmd}, exit_code: {exit_code}, output: {output.decode()}")
list_dict = json.loads(output.decode())
version_list = []
for element in list_dict:
if element["name"] == name:
version_list = element["version"].split(".")
break
version_list_clean = [] version_list_clean = []
for line in output.decode().split("\n"): for element in version_list:
if not line.startswith("Version:"): try:
continue version_list_clean.append(int(element))
version_item_list = line.split(":") except ValueError:
version_list = version_item_list[1].split(".") utils.printcolor(
for element in version_list: f"Failed to decode some part of the version of {name}",
try: utils.YELLOW)
version_list_clean.append(int(element)) version_list_clean.append(element)
except ValueError:
utils.printcolor(
f"Failed to decode some part of the version of {name}",
utils.YELLOW)
version_list_clean.append(element)
if len(version_list_clean) == 0:
utils.printcolor(
f"Failed to find the version of {name}",
utils.RED)
sys.exit(1)
return version_list_clean return version_list_clean
@@ -89,17 +84,36 @@ def install_package_from_repository(name, url, vcs="git", venv=None, **kwargs):
utils.exec_cmd(cmd, **kwargs) utils.exec_cmd(cmd, **kwargs)
def setup_virtualenv(path, sudo_user=None): def install_package_from_remote_requirements(url, venv=None, **kwargs):
"""Install a Python package from a file."""
cmd = "{} install {} {}".format(
get_pip_path(venv),
"-r",
url
)
utils.exec_cmd(cmd, **kwargs)
def setup_virtualenv(path, sudo_user=None, python_version=2):
"""Install a virtualenv if needed.""" """Install a virtualenv if needed."""
if os.path.exists(path): if os.path.exists(path):
return return
if utils.dist_name().startswith("centos"): if python_version == 2:
python_binary = "python3" python_binary = "python"
packages = ["python3"] packages = ["python-virtualenv"]
if utils.dist_name() == "debian":
packages.append("virtualenv")
else: else:
python_binary = "python3" if utils.dist_name().startswith("centos"):
packages = ["python3-venv"] python_binary = "python3"
packages = ["python3"]
else:
python_binary = "python3"
packages = ["python3-venv"]
package.backend.install_many(packages) package.backend.install_many(packages)
with utils.settings(sudo_user=sudo_user): with utils.settings(sudo_user=sudo_user):
utils.exec_cmd("{} -m venv {}".format(python_binary, path)) if python_version == 2:
install_packages(["pip", "setuptools"], venv=path, upgrade=True) utils.exec_cmd("virtualenv {}".format(path))
else:
utils.exec_cmd("{} -m venv {}".format(python_binary, path))
install_packages(["pip", "setuptools\<58.0.0"], venv=path, upgrade=True)

View File

@@ -56,7 +56,8 @@ class Automx(base.Installer):
def _setup_venv(self): def _setup_venv(self):
"""Prepare a python virtualenv.""" """Prepare a python virtualenv."""
python.setup_virtualenv(self.venv_path, sudo_user=self.user) python.setup_virtualenv(
self.venv_path, sudo_user=self.user, python_version=3)
packages = [ packages = [
"future", "lxml", "ipaddress", "sqlalchemy < 2.0", "python-memcached", "future", "lxml", "ipaddress", "sqlalchemy < 2.0", "python-memcached",
"python-dateutil", "configparser" "python-dateutil", "configparser"

View File

@@ -142,7 +142,7 @@ class Backup:
""" """
Custom config : Custom config :
- DKIM keys: {{keys_storage_dir}} - DKIM keys: {{keys_storage_dir}}
- Radicale collection (calendars, contacts): {{home_dir}} - Radicale collection (calendat, contacts): {{home_dir}}
- Amavis : /etc/amavis/conf.d/99-custom - Amavis : /etc/amavis/conf.d/99-custom
- Postwhite : /etc/postwhite.conf - Postwhite : /etc/postwhite.conf
Feel free to suggest to add others! Feel free to suggest to add others!

View File

@@ -42,10 +42,9 @@ class Clamav(base.Installer):
"""Additional tasks.""" """Additional tasks."""
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
user = self.config.get(self.appname, "user") user = self.config.get(self.appname, "user")
if self.config.getboolean("amavis", "enabled"): system.add_user_to_group(
system.add_user_to_group( user, self.config.get("amavis", "user")
user, self.config.get("amavis", "user") )
)
pattern = ( pattern = (
"s/^AllowSupplementaryGroups false/" "s/^AllowSupplementaryGroups false/"
"AllowSupplementaryGroups true/") "AllowSupplementaryGroups true/")

View File

@@ -4,8 +4,6 @@ import glob
import os import os
import pwd import pwd
import shutil import shutil
import stat
import uuid
from .. import database from .. import database
from .. import package from .. import package
@@ -28,14 +26,8 @@ class Dovecot(base.Installer):
"dovecot", "dovecot-pigeonhole"] "dovecot", "dovecot-pigeonhole"]
} }
config_files = [ config_files = [
"dovecot.conf", "dovecot.conf", "dovecot-dict-sql.conf.ext", "conf.d/10-ssl.conf",
"dovecot-dict-sql.conf.ext", "conf.d/10-master.conf", "conf.d/20-lmtp.conf", "conf.d/10-ssl-keys.try"]
"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 with_user = True
def setup_user(self): def setup_user(self):
@@ -46,15 +38,7 @@ class Dovecot(base.Installer):
def get_config_files(self): def get_config_files(self):
"""Additional config files.""" """Additional config files."""
_config_files = self.config_files return 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" "dovecot-sql-{}.conf.ext=dovecot-sql.conf.ext"
.format(self.dbengine), .format(self.dbengine),
"dovecot-sql-master-{}.conf.ext=dovecot-sql-master.conf.ext" "dovecot-sql-master-{}.conf.ext=dovecot-sql-master.conf.ext"
@@ -69,26 +53,17 @@ class Dovecot(base.Installer):
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
if "pop3" in self.config.get("dovecot", "extra_protocols"): if "pop3" in self.config.get("dovecot", "extra_protocols"):
packages += ["dovecot-pop3d"] packages += ["dovecot-pop3d"]
packages += super().get_packages() return super(Dovecot, self).get_packages() + packages
backports_codename = getattr(self, "backports_codename", None)
if backports_codename:
packages = [f"{package}/{backports_codename}-backports" for package in packages]
return packages
def install_packages(self): def install_packages(self):
"""Preconfigure Dovecot if needed.""" """Preconfigure Dovecot if needed."""
name, version = utils.dist_info()
name = name.lower()
if name.startswith("debian") and version.startswith("12"):
package.backend.enable_backports("bookworm")
self.backports_codename = "bookworm"
package.backend.preconfigure( package.backend.preconfigure(
"dovecot-core", "create-ssl-cert", "boolean", "false") "dovecot-core", "create-ssl-cert", "boolean", "false")
super().install_packages() super(Dovecot, self).install_packages()
def get_template_context(self): def get_template_context(self):
"""Additional variables.""" """Additional variables."""
context = super().get_template_context() context = super(Dovecot, self).get_template_context()
pw_mailbox = pwd.getpwnam(self.mailboxes_owner) pw_mailbox = pwd.getpwnam(self.mailboxes_owner)
dovecot_package = {"deb": "dovecot-core", "rpm": "dovecot"} dovecot_package = {"deb": "dovecot-core", "rpm": "dovecot"}
ssl_protocol_parameter = "ssl_protocols" ssl_protocol_parameter = "ssl_protocols"
@@ -109,14 +84,6 @@ class Dovecot(base.Installer):
# Protocols are automatically guessed on debian/ubuntu # Protocols are automatically guessed on debian/ubuntu
protocols = "" protocols = ""
oauth2_client_id, oauth2_client_secret = utils.create_oauth2_app(
"Dovecot", "dovecot", self.config)
hostname = self.config.get("general", "hostname")
oauth2_introspection_url = (
f"https://{oauth2_client_id}:{oauth2_client_secret}"
f"@{hostname}/api/o/introspect/"
)
context.update({ context.update({
"db_driver": self.db_driver, "db_driver": self.db_driver,
"mailboxes_owner_uid": pw_mailbox[2], "mailboxes_owner_uid": pw_mailbox[2],
@@ -129,24 +96,14 @@ class Dovecot(base.Installer):
"protocols": protocols, "protocols": protocols,
"ssl_protocols": ssl_protocols, "ssl_protocols": ssl_protocols,
"ssl_protocol_parameter": ssl_protocol_parameter, "ssl_protocol_parameter": ssl_protocol_parameter,
"radicale_user": self.config.get("radicale", "user"),
"radicale_auth_socket_path": os.path.basename(
self.config.get("dovecot", "radicale_auth_socket_path")),
"modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#", "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 "#", "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 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): def post_run(self):
"""Additional tasks.""" """Additional tasks."""
if self.dbengine == "postgres": if self.dbengine == "postgres":
@@ -163,8 +120,7 @@ class Dovecot(base.Installer):
self.get_file_path("fix_modoboa_postgres_schema.sql") self.get_file_path("fix_modoboa_postgres_schema.sql")
) )
for f in glob.glob("{}/*".format(self.get_file_path("conf.d"))): for f in glob.glob("{}/*".format(self.get_file_path("conf.d"))):
if os.path.isfile(f): utils.copy_file(f, "{}/conf.d".format(self.config_dir))
utils.copy_file(f, "{}/conf.d".format(self.config_dir))
# Make postlogin script executable # Make postlogin script executable
utils.exec_cmd("chmod +x /usr/local/bin/postlogin.sh") utils.exec_cmd("chmod +x /usr/local/bin/postlogin.sh")
# Only root should have read access to the 10-ssl-keys.try # Only root should have read access to the 10-ssl-keys.try
@@ -172,10 +128,6 @@ class Dovecot(base.Installer):
utils.exec_cmd("chmod 600 /etc/dovecot/conf.d/10-ssl-keys.try") utils.exec_cmd("chmod 600 /etc/dovecot/conf.d/10-ssl-keys.try")
# Add mailboxes user to dovecot group for modoboa mailbox commands. # Add mailboxes user to dovecot group for modoboa mailbox commands.
# See https://github.com/modoboa/modoboa/issues/2157. # 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') system.add_user_to_group(self.mailboxes_owner, 'dovecot')
def restart_daemon(self): def restart_daemon(self):

View File

@@ -96,7 +96,7 @@ auth_master_user_separator = *
# plain login digest-md5 cram-md5 ntlm rpa apop anonymous gssapi otp skey # plain login digest-md5 cram-md5 ntlm rpa apop anonymous gssapi otp skey
# gss-spnego # gss-spnego
# NOTE: See also disable_plaintext_auth setting. # NOTE: See also disable_plaintext_auth setting.
auth_mechanisms = plain login oauthbearer xoauth2 auth_mechanisms = plain login
## ##
## Password and user databases ## Password and user databases
@@ -120,7 +120,6 @@ auth_mechanisms = plain login oauthbearer xoauth2
#!include auth-system.conf.ext #!include auth-system.conf.ext
!include auth-sql.conf.ext !include auth-sql.conf.ext
!include auth-oauth2.conf.ext
#!include auth-ldap.conf.ext #!include auth-ldap.conf.ext
#!include auth-passwdfile.conf.ext #!include auth-passwdfile.conf.ext
#!include auth-checkpassword.conf.ext #!include auth-checkpassword.conf.ext

View File

@@ -131,6 +131,13 @@ service auth {
group = postfix group = postfix
} }
# Radicale auth
%{radicale_enabled}unix_listener %{radicale_auth_socket_path} {
%{radicale_enabled} mode = 0666
%{radicale_enabled} user = %{radicale_user}
%{radicale_enabled} group = %{radicale_user}
%{radicale_enabled}}
# Auth process is run as this user. # Auth process is run as this user.
#user = $default_internal_user #user = $default_internal_user
} }

View File

@@ -38,7 +38,7 @@ plugin {
# Identical to sieve_before, only the specified scripts are executed after the # 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 # user's script (only when keep is still in effect!). Multiple script file or
# directory paths can be specified by appending an increasing number. # directory paths can be specified by appending an increasing number.
%{do_move_spam_to_junk}sieve_after = /etc/dovecot/conf.d/custom_after_sieve #sieve_after =
#sieve_after2 = #sieve_after2 =
#sieve_after2 = (etc...) #sieve_after2 = (etc...)

View File

@@ -1,5 +0,0 @@
passdb {
driver = oauth2
mechanisms = xoauth2 oauthbearer
args = /etc/dovecot/conf.d/dovecot-oauth2.conf.ext
}

View File

@@ -1,4 +0,0 @@
require "fileinto";
if header :contains "X-Spam-Status" "Yes" {
fileinto "Junk";
}

View File

@@ -1,6 +0,0 @@
introspection_mode = post
introspection_url = %{oauth2_introspection_url}
username_attribute = username
tls_ca_cert_file = /etc/ssl/certs/ca-certificates.crt
active_attribute = active
active_value = true

View File

@@ -3,7 +3,6 @@
# #
PYTHON=%{venv_path}/bin/python PYTHON=%{venv_path}/bin/python
INSTANCE=%{instance_path} INSTANCE=%{instance_path}
MAILTO=%{cron_error_recipient}
# Operations on mailboxes # Operations on mailboxes
%{dovecot_enabled}* * * * * %{dovecot_mailboxes_owner} $PYTHON $INSTANCE/manage.py handle_mailbox_operations %{dovecot_enabled}* * * * * %{dovecot_mailboxes_owner} $PYTHON $INSTANCE/manage.py handle_mailbox_operations

View File

@@ -3,7 +3,7 @@ autostart=true
autorestart=true autorestart=true
command=%{venv_path}/bin/python %{home_dir}/instance/manage.py rqworker dkim command=%{venv_path}/bin/python %{home_dir}/instance/manage.py rqworker dkim
directory=%{home_dir} directory=%{home_dir}
user=%{dkim_user} user=%{opendkim_user}
redirect_stderr=true redirect_stderr=true
numprocs=1 numprocs=1
stopsignal=TERM stopsignal=TERM

View File

@@ -37,20 +37,7 @@ server {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
%{rspamd_enabled} location /rspamd/ { location ^~ /new-admin {
%{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;
uwsgi_pass modoboa;
}
location / {
alias %{app_instance_path}/frontend/; alias %{app_instance_path}/frontend/;
index index.html; index index.html;
@@ -61,5 +48,10 @@ server {
try_files $uri $uri/ /index.html = 404; try_files $uri $uri/ /index.html = 404;
} }
location / {
include uwsgi_params;
uwsgi_param UWSGI_SCRIPT instance.wsgi:application;
uwsgi_pass modoboa;
}
%{extra_config} %{extra_config}
} }

View File

@@ -1,11 +0,0 @@
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

View File

@@ -48,13 +48,11 @@ smtpd_tls_security_level = may
smtpd_tls_received_header = yes smtpd_tls_received_header = yes
# Disallow SSLv2 and SSLv3, only accept secure ciphers # Disallow SSLv2 and SSLv3, only accept secure ciphers
smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 smtpd_tls_protocols = !SSLv2, !SSLv3
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3
smtpd_tls_mandatory_ciphers = high smtpd_tls_mandatory_ciphers = high
smtpd_tls_mandatory_exclude_ciphers = aNULL, eNULL, EXPORT, DES, RC4, MD5, PSK, aECDH, EDH-DSS-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA, KRB5-DES, CBC3-SHA, CAMELLIA, SEED-SHA, AES256-SHA, AES256-SHA256, AES256-GCM-SHA384, AES128-SHA, AES128-SHA256, AES128-GCM-SHA256, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA, DHE-RSA-AES256-SHA256, DHE-RSA-CHACHA20-POLY1305, ECDHE-RSA-AES128-SHA, ECDHE-RSA-AES256-SHA smtpd_tls_mandatory_exclude_ciphers = aNULL, MD5 , DES, ADH, RC4, PSD, SRP, 3DES, eNULL
smtpd_tls_exclude_ciphers = aNULL, eNULL, EXPORT, DES, RC4, MD5, PSK, aECDH, EDH-DSS-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA, KRB5-DES, CBC3-SHA, CAMELLIA, SEED-SHA, AES256-SHA, AES256-SHA256, AES256-GCM-SHA384, AES128-SHA, AES128-SHA256, AES128-GCM-SHA256, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA, DHE-RSA-AES256-SHA256, DHE-RSA-CHACHA20-POLY1305, ECDHE-RSA-AES128-SHA, ECDHE-RSA-AES256-SHA smtpd_tls_exclude_ciphers = aNULL, MD5 , DES, ADH, RC4, PSD, SRP, 3DES, eNULL
tls_preempt_cipherlist = yes
tls_ssl_options = NO_COMPRESSION
# Enable elliptic curve cryptography # Enable elliptic curve cryptography
smtpd_tls_eecdh_grade = strong smtpd_tls_eecdh_grade = strong
@@ -122,19 +120,10 @@ strict_rfc821_envelopes = yes
%{opendkim_enabled}milter_default_action = accept %{opendkim_enabled}milter_default_action = accept
%{opendkim_enabled}milter_content_timeout = 30s %{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 # List of authorized senders
smtpd_sender_login_maps = smtpd_sender_login_maps =
proxy:%{db_driver}:/etc/postfix/sql-sender-login-map.cf 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 # Recipient restriction rules
smtpd_recipient_restrictions = smtpd_recipient_restrictions =
check_policy_service inet:127.0.0.1:9999 check_policy_service inet:127.0.0.1:9999
@@ -151,27 +140,28 @@ smtpd_recipient_restrictions =
## Postcreen settings ## Postcreen settings
# #
%{rspamd_disabled}postscreen_access_list = postscreen_access_list =
%{rspamd_disabled} permit_mynetworks permit_mynetworks
%{rspamd_disabled} cidr:/etc/postfix/postscreen_spf_whitelist.cidr cidr:/etc/postfix/postscreen_spf_whitelist.cidr
%{rspamd_disabled}postscreen_blacklist_action = enforce postscreen_blacklist_action = enforce
# Use some DNSBL # Use some DNSBL
%{rspamd_disabled}postscreen_dnsbl_sites = postscreen_dnsbl_sites =
%{rspamd_disabled} zen.spamhaus.org=127.0.0.[2..11]*3 zen.spamhaus.org=127.0.0.[2..11]*3
%{rspamd_disabled} bl.spameatingmonkey.net=127.0.0.2*2 bl.spameatingmonkey.net=127.0.0.2*2
%{rspamd_disabled} bl.spamcop.net=127.0.0.2 bl.spamcop.net=127.0.0.2
%{rspamd_disabled}postscreen_dnsbl_threshold = 3 dnsbl.sorbs.net=127.0.0.[2..15]
%{rspamd_disabled}postscreen_dnsbl_action = enforce postscreen_dnsbl_threshold = 3
postscreen_dnsbl_action = enforce
%{rspamd_disabled}postscreen_greet_banner = Welcome, please wait... postscreen_greet_banner = Welcome, please wait...
%{rspamd_disabled}postscreen_greet_action = enforce postscreen_greet_action = enforce
%{rspamd_disabled}postscreen_pipelining_enable = yes postscreen_pipelining_enable = yes
%{rspamd_disabled}postscreen_pipelining_action = enforce postscreen_pipelining_action = enforce
%{rspamd_disabled}postscreen_non_smtp_command_enable = yes postscreen_non_smtp_command_enable = yes
%{rspamd_disabled}postscreen_non_smtp_command_action = enforce postscreen_non_smtp_command_action = enforce
%{rspamd_disabled}postscreen_bare_newline_enable = yes postscreen_bare_newline_enable = yes
%{rspamd_disabled}postscreen_bare_newline_action = enforce postscreen_bare_newline_action = enforce

View File

@@ -9,8 +9,7 @@
# service type private unpriv chroot wakeup maxproc command + args # service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (yes) (never) (100) # (yes) (yes) (yes) (never) (100)
# ========================================================================== # ==========================================================================
%{rspamd_disabled}smtp inet n - - - 1 postscreen smtp inet n - - - 1 postscreen
%{rspamd_enabled}smtp inet n - - - - smtpd
smtpd pass - - - - - smtpd smtpd pass - - - - - smtpd
%{amavis_enabled} -o smtpd_proxy_filter=inet:[127.0.0.1]:10024 %{amavis_enabled} -o smtpd_proxy_filter=inet:[127.0.0.1]:10024
%{amavis_enabled} -o smtpd_proxy_options=speed_adjust %{amavis_enabled} -o smtpd_proxy_options=speed_adjust
@@ -27,7 +26,6 @@ submission inet n - - - - smtpd
-o smtpd_helo_restrictions= -o smtpd_helo_restrictions=
-o smtpd_sender_restrictions=reject_sender_login_mismatch -o smtpd_sender_restrictions=reject_sender_login_mismatch
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o cleanup_service_name=ascleanup
%{amavis_enabled} -o smtpd_proxy_filter=inet:[127.0.0.1]:10026 %{amavis_enabled} -o smtpd_proxy_filter=inet:[127.0.0.1]:10026
#smtps inet n - - - - smtpd #smtps inet n - - - - smtpd
# -o syslog_name=postfix/smtps # -o syslog_name=postfix/smtps
@@ -43,8 +41,6 @@ submission inet n - - - - smtpd
#628 inet n - - - - qmqpd #628 inet n - - - - qmqpd
pickup unix n - - 60 1 pickup pickup unix n - - 60 1 pickup
cleanup unix n - - - 0 cleanup 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 qmgr
#qmgr unix n - n 300 1 oqmgr #qmgr unix n - n 300 1 oqmgr
tlsmgr unix - - - 1000? 1 tlsmgr tlsmgr unix - - - 1000? 1 tlsmgr
@@ -128,6 +124,11 @@ mailman unix - n n - - pipe
flags=FR user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py flags=FR user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py
${nexthop} ${user} ${nexthop} ${user}
# Modoboa autoreply service
#
autoreply unix - n n - - pipe
flags= user=%{dovecot_mailboxes_owner}:%{dovecot_mailboxes_owner} argv=%{modoboa_venv_path}/bin/python %{modoboa_instance_path}/manage.py autoreply $sender $mailbox
# Amavis return path # Amavis return path
# #
%{amavis_enabled}127.0.0.1:10025 inet n - n - - smtpd %{amavis_enabled}127.0.0.1:10025 inet n - n - - smtpd
@@ -148,4 +149,4 @@ mailman unix - n n - - pipe
%{amavis_enabled} -o smtpd_client_connection_count_limit=0 %{amavis_enabled} -o smtpd_client_connection_count_limit=0
%{amavis_enabled} -o smtpd_client_connection_rate_limit=0 %{amavis_enabled} -o smtpd_client_connection_rate_limit=0
%{amavis_enabled} -o receive_override_options=no_header_body_checks,no_unknown_recipient_checks %{amavis_enabled} -o receive_override_options=no_header_body_checks,no_unknown_recipient_checks
%{amavis_enabled} -o local_header_rewrite_clients=permit_mynetworks,permit_sasl_authenticated %{amavis_enabled} -o local_header_rewrite_clients=

View File

@@ -71,7 +71,7 @@
# Authentication method # Authentication method
# Value: none | htpasswd | remote_user | http_x_remote_user # Value: none | htpasswd | remote_user | http_x_remote_user
type = radicale_modoboa_auth_oauth2 type = radicale_dovecot_auth
# Htpasswd filename # Htpasswd filename
# htpasswd_filename = users # htpasswd_filename = users
@@ -85,7 +85,7 @@ type = radicale_modoboa_auth_oauth2
# Incorrect authentication delay (seconds) # Incorrect authentication delay (seconds)
#delay = 1 #delay = 1
oauth2_introspection_endpoint = %{oauth2_introspection_url} auth_socket = %{auth_socket_path}
[rights] [rights]

View File

@@ -1,14 +0,0 @@
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";
}
}

View File

@@ -1,3 +0,0 @@
try_fallback = false;
selector_map = "%selector_map_path";
path_map = "%key_map_path";

View File

@@ -1,3 +0,0 @@
try_fallback = false;
selector_map = "%selector_map_path";
path_map = "%key_map_path";

View File

@@ -1,21 +0,0 @@
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
}

View File

@@ -1,5 +0,0 @@
rules {
DMARC_POLICY_QUARANTINE {
action = "add header";
}
}

View File

@@ -1,2 +0,0 @@
%{greylisting_disabled}enabled = false;
servers = "127.0.0.1:6379";

View File

@@ -1,5 +0,0 @@
symbols {
"WHITELIST_AUTHENTICATED" {
weight = %whitelist_auth_weigth;
}
}

View File

@@ -1,20 +0,0 @@
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";
}
}

View File

@@ -1,16 +0,0 @@
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"];
}
}

View File

@@ -1 +0,0 @@
enabled = true;

View File

@@ -1,6 +0,0 @@
# to disable all predefined rules if the user doesn't want dnsbl
url_whitelist = [];
rbls {
}

View File

@@ -1,2 +0,0 @@
write_servers = "localhost";
read_servers = "localhost";

View File

@@ -1,8 +0,0 @@
authenticated {
priority = high;
authenticated = yes;
apply {
groups_disabled = ["rbl", "spf"];
}
%{whitelist_auth_enabled} symbols ["WHITELIST_AUTHENTICATED"];
}

View File

@@ -1,6 +0,0 @@
spf_cache_size = 1k;
spf_cache_expire = 1d;
max_dns_nesting = 10;
max_dns_requests = 30;
min_cache_ttl = 5m;
disable_ipv6 = false;

View File

@@ -1 +0,0 @@
enable_password = %controller_password

View File

@@ -1 +0,0 @@
enabled = false;

View File

@@ -1,3 +0,0 @@
upstream "local" {
self_scan = yes;
}

View File

@@ -13,5 +13,3 @@ socket = %uwsgi_socket_path
chmod-socket = 660 chmod-socket = 660
vacuum = true vacuum = true
single-interpreter = True single-interpreter = True
max-requests = 5000
buffer-size = 8192

View File

@@ -49,46 +49,40 @@ class Modoboa(base.Installer):
self.instance_path = self.config.get("modoboa", "instance_path") self.instance_path = self.config.get("modoboa", "instance_path")
self.extensions = self.config.get("modoboa", "extensions").split() self.extensions = self.config.get("modoboa", "extensions").split()
self.devmode = self.config.getboolean("modoboa", "devmode") self.devmode = self.config.getboolean("modoboa", "devmode")
self.amavis_enabled = self.config.getboolean("amavis", "enabled") # 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")
if "modoboa-radicale" in self.extensions:
if not self.config.getboolean("radicale", "enabled"):
self.extensions.remove("modoboa-radicale")
self.dovecot_enabled = self.config.getboolean("dovecot", "enabled") self.dovecot_enabled = self.config.getboolean("dovecot", "enabled")
self.opendkim_enabled = self.config.getboolean("opendkim", "enabled") self.opendkim_enabled = self.config.getboolean("opendkim", "enabled")
self.dkim_cron_enabled = False self.dkim_cron_enabled = False
def is_extension_ok_for_version(self, extension, version): def is_extension_ok_for_version(self, extension, version):
"""Check if extension can be installed with this modo version.""" """Check if extension can be installed with this modo version."""
if extension not in compatibility_matrix.EXTENSIONS_AVAILABILITY:
return True
version = utils.convert_version_to_int(version) version = utils.convert_version_to_int(version)
if extension in compatibility_matrix.EXTENSIONS_AVAILABILITY: min_version = compatibility_matrix.EXTENSIONS_AVAILABILITY[extension]
min_version = compatibility_matrix.EXTENSIONS_AVAILABILITY[extension] min_version = utils.convert_version_to_int(min_version)
min_version = utils.convert_version_to_int(min_version) return version >= min_version
return version >= min_version
if extension in compatibility_matrix.REMOVED_EXTENSIONS:
max_version = compatibility_matrix.REMOVED_EXTENSIONS[extension]
max_version = utils.convert_version_to_int(max_version)
return version < max_version
return True
def _setup_venv(self): def _setup_venv(self):
"""Prepare a dedicated virtualenv.""" """Prepare a dedicated virtualenv."""
python.setup_virtualenv(self.venv_path, sudo_user=self.user) python.setup_virtualenv(
self.venv_path, sudo_user=self.user, python_version=3)
packages = ["rrdtool"] packages = ["rrdtool"]
version = self.config.get("modoboa", "version") version = self.config.get("modoboa", "version")
extras = "postgresql"
if self.dbengine != "postgres":
extras = "mysql"
if self.devmode:
extras += ",dev"
if version == "latest": if version == "latest":
packages += [f"modoboa[{extras}]"] + self.extensions packages += ["modoboa"] + self.extensions
for extension in list(self.extensions):
if extension in compatibility_matrix.REMOVED_EXTENSIONS.keys():
self.extensions.remove(extension)
self.extensions = [
extension for extension in self.extensions
if extension not in compatibility_matrix.REMOVED_EXTENSIONS
]
else: else:
matrix = compatibility_matrix.COMPATIBILITY_MATRIX[version] matrix = compatibility_matrix.COMPATIBILITY_MATRIX[version]
packages.append(f"modoboa[{extras}]=={version}") packages.append("modoboa=={}".format(version))
for extension in list(self.extensions): for extension in list(self.extensions):
if not self.is_extension_ok_for_version(extension, version): if not self.is_extension_ok_for_version(extension, version):
self.extensions.remove(extension) self.extensions.remove(extension)
@@ -97,8 +91,8 @@ class Modoboa(base.Installer):
req_version = matrix[extension] req_version = matrix[extension]
if req_version is None: if req_version is None:
continue continue
req_version = req_version.replace("<", "\\<") req_version = req_version.replace("<", "\<")
req_version = req_version.replace(">", "\\>") req_version = req_version.replace(">", "\>")
packages.append("{}{}".format(extension, req_version)) packages.append("{}{}".format(extension, req_version))
else: else:
packages.append(extension) packages.append(extension)
@@ -112,6 +106,25 @@ class Modoboa(base.Installer):
beta=self.config.getboolean("modoboa", "install_beta") beta=self.config.getboolean("modoboa", "install_beta")
) )
# Install version specific modules to the venv
modoboa_version = ".".join(str(i) for i in python.get_package_version(
"modoboa", self.venv_path, sudo_user=self.user
))
# Database:
db_file = "postgresql"
if self.dbengine != "postgres":
db_file = "mysql"
db_file += "-requirements.txt"
python.install_package_from_remote_requirements(
f"https://raw.githubusercontent.com/modoboa/modoboa/{modoboa_version}/{db_file}",
venv=self.venv_path)
# Dev mode:
if self.devmode:
python.install_package_from_remote_requirements(
f"https://raw.githubusercontent.com/modoboa/modoboa/{modoboa_version}/dev-requirements.txt",
venv=self.venv_path)
def _deploy_instance(self): def _deploy_instance(self):
"""Deploy Modoboa.""" """Deploy Modoboa."""
target = os.path.join(self.home_dir, "instance") target = os.path.join(self.home_dir, "instance")
@@ -234,8 +247,9 @@ class Modoboa(base.Installer):
), ),
"dovecot_mailboxes_owner": ( "dovecot_mailboxes_owner": (
self.config.get("dovecot", "mailboxes_owner")), self.config.get("dovecot", "mailboxes_owner")),
"radicale_enabled": (
"" if "modoboa-radicale" in extensions else "#"),
"opendkim_user": self.config.get("opendkim", "user"), "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), "minutes": random.randint(1, 59),
"hours": f"{random_hour},{random_hour+12}", "hours": f"{random_hour},{random_hour+12}",
"modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#", "modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#",
@@ -266,7 +280,7 @@ class Modoboa(base.Installer):
"pdfcredentials": { "pdfcredentials": {
"storage_dir": pdf_storage_dir "storage_dir": pdf_storage_dir
}, },
"calendars": { "modoboa_radicale": {
"server_location": "https://{}/radicale/".format( "server_location": "https://{}/radicale/".format(
self.config.get("general", "hostname")), self.config.get("general", "hostname")),
"rights_file_path": "{}/rights".format( "rights_file_path": "{}/rights".format(
@@ -279,15 +293,6 @@ class Modoboa(base.Installer):
if self.config.getboolean("opendkim", "enabled"): if self.config.getboolean("opendkim", "enabled"):
settings["admin"]["dkim_keys_storage_dir"] = ( settings["admin"]["dkim_keys_storage_dir"] = (
self.config.get("opendkim", "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) settings = json.dumps(settings)
query = ( query = (
"UPDATE core_localconfig SET _parameters='{}'" "UPDATE core_localconfig SET _parameters='{}'"

View File

@@ -21,7 +21,7 @@ class Nginx(base.Installer):
def get_template_context(self, app): def get_template_context(self, app):
"""Additionnal variables.""" """Additionnal variables."""
context = super().get_template_context() context = super(Nginx, self).get_template_context()
context.update({ context.update({
"app_instance_path": ( "app_instance_path": (
self.config.get(app, "instance_path")), self.config.get(app, "instance_path")),

View File

@@ -18,9 +18,10 @@ class Postfix(base.Installer):
appname = "postfix" appname = "postfix"
packages = { packages = {
"deb": ["postfix", "postfix-pcre"], "deb": ["postfix"],
"rpm": ["postfix"],
} }
config_files = ["main.cf", "master.cf", "anonymize_headers.pcre"] config_files = ["main.cf", "master.cf"]
def get_packages(self): def get_packages(self):
"""Additional packages.""" """Additional packages."""
@@ -28,7 +29,7 @@ class Postfix(base.Installer):
packages = ["postfix-{}".format(self.db_driver)] packages = ["postfix-{}".format(self.db_driver)]
else: else:
packages = [] packages = []
return super().get_packages() + packages return super(Postfix, self).get_packages() + packages
def install_packages(self): def install_packages(self):
"""Preconfigure postfix package installation.""" """Preconfigure postfix package installation."""
@@ -45,7 +46,7 @@ class Postfix(base.Installer):
package.backend.preconfigure( package.backend.preconfigure(
"postfix", "main_mailer_type", "select", "No configuration") "postfix", "main_mailer_type", "select", "No configuration")
super().install_packages() super(Postfix, self).install_packages()
def get_template_context(self): def get_template_context(self):
"""Additional variables.""" """Additional variables."""
@@ -59,9 +60,7 @@ class Postfix(base.Installer):
"modoboa_instance_path": self.config.get( "modoboa_instance_path": self.config.get(
"modoboa", "instance_path"), "modoboa", "instance_path"),
"opendkim_port": self.config.get( "opendkim_port": self.config.get(
"opendkim", "port"), "opendkim", "port")
"rspamd_disabled": "" if not self.config.getboolean(
"rspamd", "enabled") else "#"
}) })
return context return context
@@ -102,18 +101,8 @@ class Postfix(base.Installer):
utils.exec_cmd("postalias {}".format(aliases_file)) utils.exec_cmd("postalias {}".format(aliases_file))
# Postwhite # Postwhite
condition = ( install("postwhite", self.config, self.upgrade, self.archive_path)
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): def backup(self, path):
"""Launch postwhite backup.""" """Launch postwhite backup."""
condition = ( backup("postwhite", self.config, path)
not self.config.getboolean("rspamd", "enabled") and
self.config.getboolean("postwhite", "enabled")
)
if condition:
backup("postwhite", self.config, path)

View File

@@ -31,9 +31,10 @@ class Radicale(base.Installer):
def _setup_venv(self): def _setup_venv(self):
"""Prepare a dedicated virtualenv.""" """Prepare a dedicated virtualenv."""
python.setup_virtualenv(self.venv_path, sudo_user=self.user) python.setup_virtualenv(
self.venv_path, sudo_user=self.user, python_version=3)
packages = [ packages = [
"Radicale", "pytz", "radicale-modoboa-auth-oauth2" "Radicale", "radicale-dovecot-auth", "pytz"
] ]
python.install_packages(packages, self.venv_path, sudo_user=self.user) python.install_packages(packages, self.venv_path, sudo_user=self.user)
python.install_package_from_repository( python.install_package_from_repository(
@@ -43,22 +44,17 @@ class Radicale(base.Installer):
def get_template_context(self): def get_template_context(self):
"""Additional variables.""" """Additional variables."""
context = super().get_template_context() context = super(Radicale, self).get_template_context()
oauth2_client_id, oauth2_client_secret = utils.create_oauth2_app( radicale_auth_socket_path = self.config.get(
"Radicale", "radicale", self.config) "dovecot", "radicale_auth_socket_path")
hostname = self.config.get("general", "hostname")
oauth2_introspection_url = (
f"https://{oauth2_client_id}:{oauth2_client_secret}"
f"@{hostname}/api/o/introspect/"
)
context.update({ context.update({
"oauth2_introspection_url": oauth2_introspection_url, "auth_socket_path": radicale_auth_socket_path
}) })
return context return context
def get_config_files(self): def get_config_files(self):
"""Return appropriate path.""" """Return appropriate path."""
config_files = super().get_config_files() config_files = super(Radicale, self).get_config_files()
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
path = "supervisor=/etc/supervisor/conf.d/radicale.conf" path = "supervisor=/etc/supervisor/conf.d/radicale.conf"
else: else:

View File

@@ -1,154 +0,0 @@
"""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.")

View File

@@ -7,7 +7,7 @@ from . import package
from . import utils from . import utils
class CertificateBackend: class CertificateBackend(object):
"""Base class.""" """Base class."""
def __init__(self, config): def __init__(self, config):
@@ -24,44 +24,13 @@ class CertificateBackend:
return False return False
return True return True
def generate_cert(self):
"""Create a certificate."""
pass
class ManualCertificate(CertificateBackend):
"""Use certificate provided."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
path_correct = True
self.tls_cert_file_path = self.config.get("certificate",
"tls_cert_file_path")
self.tls_key_file_path = self.config.get("certificate",
"tls_key_file_path")
if not os.path.exists(self.tls_key_file_path):
utils.error("'tls_key_file_path' path is not accessible")
path_correct = False
if not os.path.exists(self.tls_cert_file_path):
utils.error("'tls_cert_file_path' path is not accessible")
path_correct = False
if not path_correct:
sys.exit(1)
self.config.set("general", "tls_key_file",
self.tls_key_file_path)
self.config.set("general", "tls_cert_file",
self.tls_cert_file_path)
class SelfSignedCertificate(CertificateBackend): class SelfSignedCertificate(CertificateBackend):
"""Create a self signed certificate.""" """Create a self signed certificate."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Sanity checks.""" """Sanity checks."""
super().__init__(*args, **kwargs) super(SelfSignedCertificate, self).__init__(*args, **kwargs)
if self.config.has_option("general", "tls_key_file"): if self.config.has_option("general", "tls_key_file"):
# Compatibility # Compatibility
return return
@@ -96,7 +65,7 @@ class LetsEncryptCertificate(CertificateBackend):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Update config.""" """Update config."""
super().__init__(*args, **kwargs) super(LetsEncryptCertificate, self).__init__(*args, **kwargs)
self.hostname = self.config.get("general", "hostname") self.hostname = self.config.get("general", "hostname")
self.config.set("general", "tls_cert_file", ( self.config.set("general", "tls_cert_file", (
"/etc/letsencrypt/live/{}/fullchain.pem".format(self.hostname))) "/etc/letsencrypt/live/{}/fullchain.pem".format(self.hostname)))
@@ -146,24 +115,12 @@ class LetsEncryptCertificate(CertificateBackend):
cfg_file = "/etc/letsencrypt/renewal/{}.conf".format(self.hostname) cfg_file = "/etc/letsencrypt/renewal/{}.conf".format(self.hostname)
pattern = "s/authenticator = standalone/authenticator = nginx/" pattern = "s/authenticator = standalone/authenticator = nginx/"
utils.exec_cmd("perl -pi -e '{}' {}".format(pattern, cfg_file)) utils.exec_cmd("perl -pi -e '{}' {}".format(pattern, cfg_file))
with open("/etc/letsencrypt/renewal-hooks/deploy/reload-services.sh", "w") as fp:
fp.write(f"""#!/bin/bash
HOSTNAME=$(basename $RENEWED_LINEAGE)
if [ "$HOSTNAME" = "{self.hostname}" ]
then
systemctl reload dovecot
systemctl reload postfix
fi
""")
def get_backend(config): def get_backend(config):
"""Return the appropriate backend.""" """Return the appropriate backend."""
cert_type = config.get("certificate", "type") if not config.getboolean("certificate", "generate"):
if cert_type == "letsencrypt": return None
if config.get("certificate", "type") == "letsencrypt":
return LetsEncryptCertificate(config) return LetsEncryptCertificate(config)
if cert_type == "manual":
return ManualCertificate(config)
return SelfSignedCertificate(config) return SelfSignedCertificate(config)

View File

@@ -1,6 +1,5 @@
"""Utility functions.""" """Utility functions."""
import configparser
import contextlib import contextlib
import datetime import datetime
import getpass import getpass
@@ -13,10 +12,12 @@ import stat
import string import string
import subprocess import subprocess
import sys import sys
import uuid try:
import configparser
except ImportError:
import ConfigParser as configparser
from . import config_dict_template from . import config_dict_template
from .compatibility_matrix import APP_INCOMPATIBILITY
ENV = {} ENV = {}
@@ -32,7 +33,12 @@ class FatalError(Exception):
def user_input(message): def user_input(message):
"""Ask something to the user.""" """Ask something to the user."""
answer = input(message) try:
from builtins import input
except ImportError:
answer = raw_input(message)
else:
answer = input(message)
return answer return answer
@@ -95,17 +101,6 @@ def dist_name():
return dist_info()[0].lower() 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): def mkdir(path, mode, uid, gid):
"""Create a directory.""" """Create a directory."""
if not os.path.exists(path): if not os.path.exists(path):
@@ -177,29 +172,25 @@ def copy_from_template(template, dest, context):
fp.write(ConfigFileTemplate(buf).substitute(context)) fp.write(ConfigFileTemplate(buf).substitute(context))
def check_config_file(dest, def check_config_file(dest, interactive=False, upgrade=False, backup=False, restore=False):
interactive=False,
upgrade=False,
backup=False,
restore=False):
"""Create a new installer config file if needed.""" """Create a new installer config file if needed."""
is_present = True is_present = True
if os.path.exists(dest): if os.path.exists(dest):
return is_present, update_config(dest, False) return is_present, update_config(dest, False)
if upgrade: if upgrade:
error( printcolor(
"You cannot upgrade an existing installation without a " "You cannot upgrade an existing installation without a "
"configuration file.") "configuration file.", RED)
sys.exit(1) sys.exit(1)
elif backup: elif backup:
is_present = False is_present = False
error( printcolor(
"Your configuration file hasn't been found. A new one will be generated. " "Your configuration file hasn't been found. A new one will be generated. "
"Please edit it with correct password for the databases !") "Please edit it with correct password for the databases !", RED)
elif restore: elif restore:
error( printcolor(
"You cannot restore an existing installation without a " "You cannot restore an existing installation without a "
f"configuration file. (file : {dest} has not been found...") f"configuration file. (file : {dest} has not been found...", RED)
sys.exit(1) sys.exit(1)
printcolor( printcolor(
@@ -285,16 +276,6 @@ def random_key(l=16):
return key 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): def validate(value, config_entry):
if value is None: if value is None:
return False return False
@@ -315,14 +296,11 @@ def validate(value, config_entry):
return True return True
def get_entry_value(entry: dict, interactive: bool, config: configparser.ConfigParser) -> string: def get_entry_value(entry, interactive):
default_entry = entry["default"] if callable(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"]() default_value = entry["default"]()
else: else:
default_value = default_entry default_value = entry["default"]
user_value = None user_value = None
if entry.get("customizable") and interactive: if entry.get("customizable") and interactive:
while (user_value != '' and not validate(user_value, entry)): while (user_value != '' and not validate(user_value, entry)):
@@ -338,17 +316,6 @@ def get_entry_value(entry: dict, interactive: bool, config: configparser.ConfigP
if entry.get("values") and user_value != "": if entry.get("values") and user_value != "":
user_value = values[int(user_value)] user_value = values[int(user_value)]
non_interactive_values = entry.get("non_interactive_values", [])
if user_value in non_interactive_values:
error(
f"{user_value} cannot be set interactively. "
"Please configure installer.cfg manually by running "
"'python3 run.py --stop-after-configfile-check domain'. "
"Check modoboa-installer README for more information."
)
sys.exit(1)
return user_value if user_value else default_value return user_value if user_value else default_value
@@ -358,22 +325,16 @@ def load_config_template(interactive):
config = configparser.ConfigParser() config = configparser.ConfigParser()
# only ask about options we need, else still generate default # only ask about options we need, else still generate default
for section in tpl_dict: for section in tpl_dict:
interactive_section = interactive
if "if" in section: if "if" in section:
condition = check_if_condition(config, section["if"]) config_key, value = section.get("if").split("=")
interactive_section = condition and interactive section_name, option = config_key.split(".")
interactive_section = (
config.get(section_name, option) == value and interactive)
else:
interactive_section = interactive
config.add_section(section["name"]) config.add_section(section["name"])
for config_entry in section["values"]: for config_entry in section["values"]:
if config_entry.get("if") is not None: value = get_entry_value(config_entry, interactive_section)
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) config.set(section["name"], config_entry["option"], value)
return config return config
@@ -473,7 +434,7 @@ def validate_backup_path(path: str, silent_mode: bool):
if not path_exists: if not path_exists:
if not silent_mode: if not silent_mode:
create_dir = input( 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() ).lower()
if silent_mode or (not silent_mode and create_dir.startswith("y")): if silent_mode or (not silent_mode and create_dir.startswith("y")):
@@ -488,7 +449,7 @@ def validate_backup_path(path: str, silent_mode: bool):
if len(os.listdir(path)) != 0: if len(os.listdir(path)) != 0:
if not silent_mode: if not silent_mode:
delete_dir = input( 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")): if silent_mode or (not silent_mode and delete_dir.startswith("y")):
try: try:
@@ -513,33 +474,3 @@ def validate_backup_path(path: str, silent_mode: bool):
mkdir_safe(os.path.join(backup_path, dir), mkdir_safe(os.path.join(backup_path, dir),
stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3]) stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
return backup_path return backup_path
def create_oauth2_app(app_name: str, client_id: str, config) -> tuple[str, str]:
"""Create a application for Oauth2 authentication."""
# FIXME: how can we check that application already exists ?
venv_path = config.get("modoboa", "venv_path")
python_path = os.path.join(venv_path, "bin", "python")
instance_path = config.get("modoboa", "instance_path")
script_path = os.path.join(instance_path, "manage.py")
client_secret = str(uuid.uuid4())
cmd = (
f"{python_path} {script_path} createapplication "
f"--name={app_name} --skip-authorization "
f"--client-id={client_id} --client-secret={client_secret} "
f"confidential client-credentials"
)
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

138
run.py
View File

@@ -3,12 +3,14 @@
"""An installer for Modoboa.""" """An installer for Modoboa."""
import argparse import argparse
import configparser
import datetime import datetime
import os import os
try:
import configparser
except ImportError:
import ConfigParser as configparser
import sys import sys
from modoboa_installer import checks
from modoboa_installer import compatibility_matrix from modoboa_installer import compatibility_matrix
from modoboa_installer import constants from modoboa_installer import constants
from modoboa_installer import package from modoboa_installer import package
@@ -16,24 +18,69 @@ from modoboa_installer import scripts
from modoboa_installer import ssl from modoboa_installer import ssl
from modoboa_installer import system from modoboa_installer import system
from modoboa_installer import utils from modoboa_installer import utils
from modoboa_installer import disclaimers
PRIMARY_APPS = [ PRIMARY_APPS = [
"amavis",
"fail2ban", "fail2ban",
"modoboa", "modoboa",
"automx", "automx",
"radicale", "radicale",
"uwsgi", "uwsgi",
"nginx", "nginx",
"opendkim",
"postfix", "postfix",
"dovecot" "dovecot"
] ]
def installation_disclaimer(args, config):
"""Display installation disclaimer."""
hostname = config.get("general", "hostname")
utils.printcolor(
"Warning:\n"
"Before you start the installation, please make sure the following "
"DNS records exist for domain '{}':\n"
" {} IN A <IP ADDRESS OF YOUR SERVER>\n"
" @ IN MX {}.\n".format(
args.domain,
hostname.replace(".{}".format(args.domain), ""),
hostname
),
utils.CYAN
)
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): def backup_system(config, args):
"""Launch backup procedure.""" """Launch backup procedure."""
disclaimers.backup_disclaimer() backup_disclaimer()
backup_path = None backup_path = None
if args.silent_backup: if args.silent_backup:
if not args.backup_path: if not args.backup_path:
@@ -66,9 +113,6 @@ def backup_system(config, args):
utils.copy_file(args.configfile, backup_path) utils.copy_file(args.configfile, backup_path)
# Backup applications # Backup applications
for app in PRIMARY_APPS: for app in PRIMARY_APPS:
if app == "dovecot" and args.no_mail:
utils.printcolor("Skipping mail backup", utils.BLUE)
continue
scripts.backup(app, config, backup_path) scripts.backup(app, config, backup_path)
@@ -81,11 +125,12 @@ def config_file_update_complete(backup_location):
utils.BLUE) utils.BLUE)
def parser_setup(input_args): def main(input_args):
"""Install process."""
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
versions = ( versions = (
["latest"] + list(compatibility_matrix.COMPATIBILITY_MATRIX.keys()) ["latest"] + list(compatibility_matrix.COMPATIBILITY_MATRIX.keys())
) )
parser.add_argument("--debug", action="store_true", default=False, parser.add_argument("--debug", action="store_true", default=False,
help="Enable debug output") help="Enable debug output")
parser.add_argument("--force", action="store_true", default=False, parser.add_argument("--force", action="store_true", default=False,
@@ -113,31 +158,20 @@ def parser_setup(input_args):
parser.add_argument( parser.add_argument(
"--backup", action="store_true", default=False, "--backup", action="store_true", default=False,
help="Backing up interactively previously installed instance" help="Backing up interactively previously installed instance"
) )
parser.add_argument( parser.add_argument(
"--silent-backup", action="store_true", default=False, "--silent-backup", action="store_true", default=False,
help="For script usage, do not require user interaction " help="For script usage, do not require user interaction "
"backup will be saved at ./modoboa_backup/Backup_M_Y_d_H_M " "backup will be saved at ./modoboa_backup/Backup_M_Y_d_H_M "
"if --backup-path is not provided") "if --backup-path is not provided")
parser.add_argument(
"--no-mail", action="store_true", default=False,
help="Disable mail backup (save space)")
parser.add_argument( parser.add_argument(
"--restore", type=str, metavar="path", "--restore", type=str, metavar="path",
help="Restore a previously backup up modoboa instance on a NEW machine. " help="Restore a previously backup up modoboa instance on a NEW machine. "
"You MUST provide backup directory" "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, parser.add_argument("domain", type=str,
help="The main domain of your future mail server") help="The main domain of your future mail server")
return parser.parse_args(input_args) args = parser.parse_args(input_args)
def main(input_args):
"""Install process."""
args = parser_setup(input_args)
if args.debug: if args.debug:
utils.ENV["debug"] = True utils.ENV["debug"] = True
@@ -155,12 +189,6 @@ def main(input_args):
utils.success("Welcome to Modoboa installer!\n") utils.success("Welcome to Modoboa installer!\n")
# Checks
if not args.skip_checks:
utils.printcolor("Checking the installer...", utils.BLUE)
checks.handle()
utils.success("Checks complete\n")
is_config_file_available, outdate_config = utils.check_config_file( is_config_file_available, outdate_config = utils.check_config_file(
args.configfile, args.interactive, args.upgrade, args.backup, is_restoring) args.configfile, args.interactive, args.upgrade, args.backup, is_restoring)
@@ -173,11 +201,11 @@ def main(input_args):
if is_config_file_available and outdate_config: if is_config_file_available and outdate_config:
answer = utils.user_input("It seems that your config file is outdated. " answer = utils.user_input("It seems that your config file is outdated. "
"Would you like to update it? (Y/n) ") "Would you like to update it? (Y/n) ")
if not answer or answer.lower().startswith("y"): if answer.lower().startswith("y"):
config_file_update_complete(utils.update_config(args.configfile)) config_file_update_complete(utils.update_config(args.configfile))
if not args.stop_after_configfile_check: if not args.stop_after_configfile_check:
answer = utils.user_input("Would you like to stop to review the updated config? (Y/n)") answer = utils.user_input("Would you like to stop to review the updated config? (Y/n)")
if not answer or answer.lower().startswith("y"): if answer.lower().startswith("y"):
return return
else: else:
utils.error("You might encounter unexpected errors ! " utils.error("You might encounter unexpected errors ! "
@@ -196,36 +224,28 @@ def main(input_args):
config.set("modoboa", "version", args.version) config.set("modoboa", "version", args.version)
config.set("modoboa", "install_beta", str(args.beta)) 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: if args.backup or args.silent_backup:
backup_system(config, args) backup_system(config, args)
return return
# Display disclaimer python 3 linux distribution # Display disclaimer python 3 linux distribution
if args.upgrade: if args.upgrade:
disclaimers.upgrade_disclaimer(config) upgrade_disclaimer(config)
elif args.restore: elif args.restore:
disclaimers.restore_disclaimer() restore_disclaimer()
scripts.restore_prep(args.restore) scripts.restore_prep(args.restore)
else: else:
disclaimers.installation_disclaimer(args, config) installation_disclaimer(args, config)
# Show concerned components # Show concerned components
components = [] components = []
for section in config.sections(): for section in config.sections():
if section in ["general", "antispam", "database", "mysql", "postgres", if section in ["general", "database", "mysql", "postgres",
"certificate", "letsencrypt", "backup"]: "certificate", "letsencrypt"]:
continue continue
if (config.has_option(section, "enabled") and if (config.has_option(section, "enabled") and
not config.getboolean(section, "enabled")): not config.getboolean(section, "enabled")):
continue continue
incompatible_app_detected = not utils.check_app_compatibility(section, config)
if incompatible_app_detected:
sys.exit(0)
components.append(section) components.append(section)
utils.printcolor(" ".join(components), utils.YELLOW) utils.printcolor(" ".join(components), utils.YELLOW)
if not args.force: if not args.force:
@@ -242,40 +262,20 @@ def main(input_args):
ssl_backend = ssl.get_backend(config) ssl_backend = ssl.get_backend(config)
if ssl_backend and not args.upgrade: if ssl_backend and not args.upgrade:
ssl_backend.generate_cert() ssl_backend.generate_cert()
for appname in PRIMARY_APPS + antispam_apps: for appname in PRIMARY_APPS:
scripts.install(appname, config, args.upgrade, args.restore) scripts.install(appname, config, args.upgrade, args.restore)
system.restart_service("cron") system.restart_service("cron")
package.backend.restore_system() package.backend.restore_system()
hostname = config.get("general", "hostname")
if not args.restore: if not args.restore:
utils.success( utils.success(
f"Congratulations! You can enjoy Modoboa at https://{hostname} " "Congratulations! You can enjoy Modoboa at https://{} (admin:password)"
"(admin:password)" .format(config.get("general", "hostname"))
) )
else: else:
utils.success( utils.success(
f"Restore complete! You can enjoy Modoboa at https://{hostname} " "Restore complete! You can enjoy Modoboa at https://{} (same credentials as before)"
"(same credentials as before)" .format(config.get("general", "hostname"))
) )
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"
"Modoboa is a free software maintained by volunteers.\n"
"You like the project and want it to be sustainable?\n"
"Then don't wait anymore and go sponsor it here:\n"
)
utils.printcolor(
"https://github.com/sponsors/modoboa\n",
utils.YELLOW
)
utils.success(
"Thank you for your help :-)\n"
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -47,7 +47,7 @@ class ConfigFileTestCase(unittest.TestCase):
def test_interactive_mode(self, mock_user_input): def test_interactive_mode(self, mock_user_input):
"""Check interactive mode.""" """Check interactive mode."""
mock_user_input.side_effect = [ mock_user_input.side_effect = [
"0", "0", "", "", "", "", "", "" "0", "0", "", "", "", "", ""
] ]
with open(os.devnull, "w") as fp: with open(os.devnull, "w") as fp:
sys.stdout = fp sys.stdout = fp
@@ -99,7 +99,7 @@ class ConfigFileTestCase(unittest.TestCase):
def test_interactive_mode_letsencrypt(self, mock_user_input): def test_interactive_mode_letsencrypt(self, mock_user_input):
"""Check interactive mode.""" """Check interactive mode."""
mock_user_input.side_effect = [ mock_user_input.side_effect = [
"0", "0", "1", "admin@example.test", "0", "", "", "", "" "1", "admin@example.test", "0", "", "", "", "", ""
] ]
with open(os.devnull, "w") as fp: with open(os.devnull, "w") as fp:
sys.stdout = fp sys.stdout = fp
@@ -126,13 +126,12 @@ class ConfigFileTestCase(unittest.TestCase):
"example.test"]) "example.test"])
self.assertTrue(os.path.exists(self.cfgfile)) self.assertTrue(os.path.exists(self.cfgfile))
self.assertIn( self.assertIn(
"fail2ban modoboa automx amavis clamav dovecot nginx " "modoboa automx amavis clamav dovecot nginx razor postfix"
"postfix postwhite spamassassin uwsgi radicale opendkim", " postwhite spamassassin uwsgi",
out.getvalue() out.getvalue()
) )
self.assertNotIn( self.assertNotIn("It seems that your config file is outdated.",
"It seems that your config file is outdated.", out.getvalue()
out.getvalue()
) )
@patch("modoboa_installer.utils.user_input") @patch("modoboa_installer.utils.user_input")

View File

@@ -1 +0,0 @@
53669b48de7ce85341a547ed2583380fcb06841b