Merge branch 'master' into master

This commit is contained in:
Antoine Nguyen
2018-06-09 09:44:26 +02:00
committed by GitHub
45 changed files with 1961 additions and 187 deletions

15
.travis.yml Normal file
View File

@@ -0,0 +1,15 @@
sudo: false
language: python
cache: pip
python:
- "2.7"
- "3.4"
before_install:
- pip install -r test-requirements.txt
script:
- coverage run tests.py
after_success:
- codecov

View File

@@ -1,6 +1,8 @@
modoboa-installer modoboa-installer
================= =================
|travis| |codecov|
An installer which deploy a complete mail server based on Modoboa. An installer which deploy a complete mail server based on Modoboa.
.. warning:: .. warning::
@@ -31,24 +33,58 @@ A configuration file will be automatically generated the first time
you run the installer, please don't copy the you run the installer, please don't copy the
``installer.cfg.template`` file manually. ``installer.cfg.template`` file manually.
By default, the following components are installed: The following components are installed by the installer:
* Database server (PostgreSQL or MySQL) * Database server (PostgreSQL or MySQL)
* Nginx and uWSGI * Nginx and uWSGI
* Postfix * Postfix
* Dovecot * Dovecot
* Amavis (with SpamAssassin and ClamAV) * Amavis (with SpamAssassin and ClamAV)
* automx (autoconfiguration service)
If you want to customize configuration before running the installer, If you want to customize configuration before running the installer,
run the following command:: run the following command::
$ ./run.py --stop-after-configfile-check <your domain> $ ./run.py --stop-after-configfile-check <your domain>
An interactive mode is also available::
$ ./run.py --interactive <your domain>
Make your modifications and run the installer as usual. Make your modifications and run the installer as usual.
By default, the latest Modoboa version is installed but you can select
a previous one using the ``--version`` option::
$ sudo ./run.py --version=X.X.X <your domain>
.. note::
Version selection is available only for Modoboa >= 1.8.1.
If you want more information about the installation process, add the If you want more information about the installation process, add the
``--debug`` option to your command line. ``--debug`` option to your command line.
Change the generated hostname
-----------------------------
By default, the installer will setup your email server using the
following hostname: ``mail.<your domain>``. If you want a different
value, generate the configuration file like this::
$ ./run.py --stop-after-configfile-check <your domain>
Then edit ``installer.cfg`` and look for the following section::
[general]
hostname = mail.%(domain)s
Replace ``mail`` by the value you want to use and save your
modifications.
Finally, run the installer without the
``--stop-after-configfile-check`` option.
Let's Encrypt certificate Let's Encrypt certificate
------------------------- -------------------------
@@ -71,3 +107,8 @@ 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.
.. |travis| image:: https://travis-ci.org/modoboa/modoboa-installer.png?branch=master
:target: https://travis-ci.org/modoboa/modoboa-installer
.. |codecov| image:: http://codecov.io/github/modoboa/modoboa-installer/coverage.svg?branch=master
:target: http://codecov.io/github/modoboa/modoboa-installer?branch=master

View File

@@ -1,103 +0,0 @@
[general]
# %(domain)s is the value specified when launching the installer
hostname = mail.%(domain)s
[certificate]
generate = true
# Choose between self-signed or letsencrypt
type = self-signed
[letsencrypt]
email = admin@example.com
[database]
# Select database engine : postgres or mysql
engine = postgres
#engine = mysql
host = 127.0.0.1
install = true
[postgres]
user = postgres
password =
[mysql]
user = root
password = $mysql_password
charset = utf8
collation = utf8_general_ci
[modoboa]
user = modoboa
home_dir = /srv/modoboa
venv_path = %(home_dir)s/env
instance_path = %(home_dir)s/instance
timezone = Europe/Paris
dbname = modoboa
dbuser = modoboa
dbpassword = $modoboa_password
# Extensions to install
# also available: modoboa-radicale modoboa-dmarc modoboa-imap-migration
extensions = modoboa-amavis modoboa-pdfcredentials modoboa-postfix-autoreply modoboa-sievefilters modoboa-stats modoboa-webmail modoboa-contacts
# Deploy Modoboa and enable development mode
devmode = false
[automx]
enabled = true
user = automx
config_dir = /etc
home_dir = /srv/automx
venv_path = %(home_dir)s/env
instance_path = %(home_dir)s/instance
[amavis]
enabled = true
user = amavis
max_servers = 1
dbname = amavis
dbuser = amavis
dbpassword = $amavis_password
[clamav]
enabled = true
user = clamav
[dovecot]
enabled = true
config_dir = /etc/dovecot
user = vmail
home_dir = /srv/vmail
mailboxes_owner = vmail
# Enable extra procotols (in addition to imap and lmtp)
# Example: pop3
extra_protocols =
# Replace localhost with your domain
postmaster_address = postmaster@localhost
[nginx]
enabled = true
config_dir = /etc/nginx
[razor]
enabled = true
config_dir = /etc/razor
[postfix]
enabled = true
config_dir = /etc/postfix
message_size_limit = 11534336
[spamassassin]
enabled = true
config_dir = /etc/mail/spamassassin
dbname = spamassassin
dbuser = spamassassin
dbpassword = $sa_password
[uwsgi]
enabled = true
config_dir = /etc/uwsgi
nb_processes = 2

View File

@@ -0,0 +1,28 @@
"""Modoboa compatibility matrix."""
COMPATIBILITY_MATRIX = {
"1.8.1": {
"modoboa-pdfcredentials": "<=1.1.0",
"modoboa-sievefilters": "<=1.1.0",
"modoboa-webmail": "<=1.1.5",
},
"1.8.2": {
"modoboa-pdfcredentials": ">=1.1.1",
"modoboa-sievefilters": ">=1.1.1",
"modoboa-webmail": ">=1.2.0",
},
"1.8.3": {
"modoboa-pdfcredentials": ">=1.1.1",
"modoboa-sievefilters": ">=1.1.1",
"modoboa-webmail": ">=1.2.0",
},
"1.9.0": {
"modoboa-pdfcredentials": ">=1.1.1",
"modoboa-sievefilters": ">=1.1.1",
"modoboa-webmail": ">=1.2.0",
}
}
EXTENSIONS_AVAILABILITY = {
"modoboa-contacts": "1.7.4",
}

View File

@@ -0,0 +1,442 @@
import random
import string
def make_password(length=16):
"""Create a random password."""
return "".join(
random.SystemRandom().choice(
string.ascii_letters + string.digits) for _ in range(length))
# Validators should return a tuple bool, error message
def is_email(user_input):
"""Return True in input is a valid email"""
return "@" in user_input, "Please enter a valid email"
ConfigDictTemplate = [
{
"name": "general",
"values": [
{
"option": "hostname",
"default": "mail.%(domain)s",
}
]
},
{
"name": "certificate",
"values": [
{
"option": "generate",
"default": "true",
},
{
"option": "type",
"default": "self-signed",
"customizable": True,
"question": "Please choose your certificate type",
"values": ["self-signed", "letsencrypt"],
}
],
},
{
"name": "letsencrypt",
"if": "certificate.type=letsencrypt",
"values": [
{
"option": "email",
"default": "admin@example.com",
"question": (
"Please enter the mail you wish to use for "
"letsencrypt"),
"customizable": True,
"validators": [is_email]
}
]
},
{
"name": "database",
"values": [
{
"option": "engine",
"default": "postgres",
"customizable": True,
"question": "Please choose your database engine",
"values": ["postgres", "mysql"],
},
{
"option": "host",
"default": "127.0.0.1",
},
{
"option": "install",
"default": "true",
}
]
},
{
"name": "postgres",
"if": "database.engine=postgres",
"values": [
{
"option": "user",
"default": "postgres",
},
{
"option": "password",
"default": "",
"customizable": True,
"question": "Please enter postgres password",
},
]
},
{
"name": "mysql",
"if": "database.engine=mysql",
"values": [
{
"option": "user",
"default": "root",
},
{
"option": "password",
"default": make_password,
"customizable": True,
"question": "Please enter mysql root password"
},
{
"option": "charset",
"default": "utf8",
},
{
"option": "collation",
"default": "utf8_general_ci",
}
]
},
{
"name": "modoboa",
"values": [
{
"option": "user",
"default": "modoboa",
},
{
"option": "home_dir",
"default": "/srv/modoboa",
},
{
"option": "venv_path",
"default": "%(home_dir)s/env",
},
{
"option": "instance_path",
"default": "%(home_dir)s/instance",
},
{
"option": "timezone",
"default": "Europe/Paris",
},
{
"option": "dbname",
"default": "modoboa",
},
{
"option": "dbuser",
"default": "modoboa",
},
{
"option": "dbpassword",
"default": make_password,
"customizable": True,
"question": "Please enter Modoboa db password",
},
{
"option": "extensions",
"default": (
"modoboa-amavis modoboa-pdfcredentials "
"modoboa-postfix-autoreply modoboa-sievefilters "
"modoboa-stats modoboa-webmail modoboa-contacts "
"modoboa-radicale"
),
},
{
"option": "devmode",
"default": "false",
},
]
},
{
"name": "automx",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "user",
"default": "automx",
},
{
"option": "config_dir",
"default": "/etc",
},
{
"option": "home_dir",
"default": "/srv/automx",
},
{
"option": "venv_path",
"default": "%(home_dir)s/env",
},
{
"option": "instance_path",
"default": "%(home_dir)s/instance",
},
]
},
{
"name": "amavis",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "user",
"default": "amavis",
},
{
"option": "max_servers",
"default": "1",
},
{
"option": "dbname",
"default": "amavis",
},
{
"option": "dbuser",
"default": "amavis",
},
{
"option": "dbpassword",
"default": make_password,
"customizable": True,
"question": "Please enter amavis db password"
},
],
},
{
"name": "clamav",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "user",
"default": "clamav",
},
]
},
{
"name": "dovecot",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "config_dir",
"default": "/etc/dovecot",
},
{
"option": "user",
"default": "vmail",
},
{
"option": "home_dir",
"default": "/srv/vmail",
},
{
"option": "mailboxes_owner",
"default": "vmail",
},
{
"option": "extra_protocols",
"default": "",
},
{
"option": "postmaster_address",
"default": "postmaster@%(domain)s",
},
{
"option": "radicale_auth_socket_path",
"default": "/var/run/dovecot/auth-radicale"
},
]
},
{
"name": "nginx",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "config_dir",
"default": "/etc/nginx",
},
],
},
{
"name": "razor",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "config_dir",
"default": "/etc/razor",
},
]
},
{
"name": "postfix",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "config_dir",
"default": "/etc/postfix",
},
{
"option": "message_size_limit",
"default": "11534336",
},
]
},
{
"name": "postwhite",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "config_dir",
"default": "/etc",
},
]
},
{
"name": "spamassassin",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "config_dir",
"default": "/etc/mail/spamassassin",
},
{
"option": "dbname",
"default": "spamassassin",
},
{
"option": "dbuser",
"default": "spamassassin",
},
{
"option": "dbpassword",
"default": make_password,
"customizable": True,
"question": "Please enter spamassassin db password"
},
]
},
{
"name": "uwsgi",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "config_dir",
"default": "/etc/uwsgi",
},
{
"option": "nb_processes",
"default": "2",
},
]
},
{
"name": "radicale",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "user",
"default": "radicale",
},
{
"option": "config_dir",
"default": "/etc/radicale",
},
{
"option": "home_dir",
"default": "/srv/radicale",
},
{
"option": "venv_path",
"default": "%(home_dir)s/env",
}
]
},
{
"name": "opendkim",
"values": [
{
"option": "enabled",
"default": "true",
},
{
"option": "user",
"default": "opendkim",
},
{
"option": "config_dir",
"default": "/etc",
},
{
"option": "port",
"default": "12345"
},
{
"option": "keys_storage_dir",
"default": "/var/lib/dkim"
},
{
"option": "dbuser",
"default": "opendkim",
},
{
"option": "dbpassword",
"default": make_password,
"customizable": True,
"question": "Please enter OpenDKIM db password"
},
]
},
]

View File

@@ -1,6 +1,7 @@
"""Database related tools.""" """Database related tools."""
import os import os
import platform
import pwd import pwd
import stat import stat
@@ -59,9 +60,11 @@ class PostgreSQL(Database):
def _exec_query(self, query, dbname=None, dbuser=None, dbpassword=None): def _exec_query(self, query, dbname=None, dbuser=None, dbpassword=None):
"""Exec a postgresql query.""" """Exec a postgresql query."""
cmd = "psql" cmd = "psql"
if dbname and dbuser: if dbname:
cmd += " -d {}".format(dbname)
if dbuser:
self._setup_pgpass(dbname, dbuser, dbpassword) self._setup_pgpass(dbname, dbuser, dbpassword)
cmd += " -h {} -d {} -U {} -w".format(self.dbhost, dbname, dbuser) cmd += " -h {} -U {} -w".format(self.dbhost, dbuser)
query = query.replace("'", "'\"'\"'") query = query.replace("'", "'\"'\"'")
cmd = "{} -c '{}' ".format(cmd, query) cmd = "{} -c '{}' ".format(cmd, query)
utils.exec_cmd(cmd, sudo_user=self.dbuser) utils.exec_cmd(cmd, sudo_user=self.dbuser)
@@ -93,6 +96,12 @@ class PostgreSQL(Database):
query = "GRANT ALL ON DATABASE {} TO {}".format(dbname, user) query = "GRANT ALL ON DATABASE {} TO {}".format(dbname, user)
self._exec_query(query) self._exec_query(query)
def grant_right_on_table(self, dbname, table, user, right):
"""Grant specific right to user on table."""
query = "GRANT {} ON {} TO {}".format(
right.upper(), table, user)
self._exec_query(query, dbname=dbname)
def _setup_pgpass(self, dbname, dbuser, dbpasswd): def _setup_pgpass(self, dbname, dbuser, dbpasswd):
"""Setup .pgpass file.""" """Setup .pgpass file."""
if self._pgpass_done: if self._pgpass_done:
@@ -113,10 +122,9 @@ class PostgreSQL(Database):
def load_sql_file(self, dbname, dbuser, dbpassword, path): def load_sql_file(self, dbname, dbuser, dbpassword, path):
"""Load SQL file.""" """Load SQL file."""
self._setup_pgpass(dbname, dbuser, dbpassword) self._setup_pgpass(dbname, dbuser, dbpassword)
utils.exec_cmd( cmd = "psql -h {} -d {} -U {} -w < {}".format(
"psql -h {} -d {} -U {} -w < {}".format( self.dbhost, dbname, dbuser, path)
self.dbhost, dbname, dbuser, path), utils.exec_cmd(cmd, sudo_user=self.dbuser)
sudo_user=self.dbuser)
class MySQL(Database): class MySQL(Database):
@@ -124,32 +132,54 @@ class MySQL(Database):
"""MySQL backend.""" """MySQL backend."""
packages = { packages = {
"deb": ["mysql-server", "libmysqlclient-dev"], "deb": ["mariadb-server"],
"rpm": ["mariadb", "mariadb-devel", "mariadb-server"], "rpm": ["mariadb", "mariadb-devel", "mariadb-server"],
} }
service = "mariadb" if package.backend.FORMAT == "rpm" else "mysql" service = "mariadb"
def _escape(self, query):
"""Replace special characters."""
return query.replace("'", "'\"'\"'")
def install_package(self): def install_package(self):
"""Preseed package installation.""" """Preseed package installation."""
package.backend.preconfigure( name, version, _id = platform.linux_distribution()
"mysql-server", "root_password", "password", self.dbpassword) name = name.lower()
package.backend.preconfigure( if name == "debian":
"mysql-server", "root_password_again", "password", self.dbpassword) mysql_name = "mysql" if version.startswith("8") else "mariadb"
self.packages["deb"].append("lib{}client-dev".format(mysql_name))
elif name == "ubuntu":
self.packages["deb"].append("libmysqlclient-dev")
super(MySQL, self).install_package() super(MySQL, self).install_package()
if package.backend.FORMAT == "rpm": if name == "debian" and version.startswith("8"):
utils.exec_cmd("mysqladmin -u root password '{}'".format( package.backend.preconfigure(
self.dbpassword)) "mariadb-server", "root_password", "password",
self.dbpassword)
package.backend.preconfigure(
"mariadb-server", "root_password_again", "password",
self.dbpassword)
else:
queries = [
"UPDATE user SET plugin='' WHERE user='root'",
"UPDATE user SET password=PASSWORD('{}') WHERE USER='root'"
.format(self.dbpassword),
"flush privileges"
]
for query in queries:
utils.exec_cmd(
"mysql -D mysql -e '{}'".format(self._escape(query)))
def _exec_query(self, query, dbname=None, dbuser=None, dbpassword=None): def _exec_query(self, query, dbname=None, dbuser=None, dbpassword=None):
"""Exec a mysql query.""" """Exec a mysql query."""
if dbuser is None and dbpassword is None: if dbuser is None and dbpassword is None:
dbuser = self.dbuser dbuser = self.dbuser
dbpassword = self.dbpassword dbpassword = self.dbpassword
cmd = "mysql -h {} -u {} -p{}".format(self.dbhost, dbuser, dbpassword) cmd = "mysql -h {} -u {}".format(self.dbhost, dbuser)
if dbpassword:
cmd += " -p{}".format(dbpassword)
if dbname: if dbname:
cmd += " -D {}".format(dbname) cmd += " -D {}".format(dbname)
query = query.replace("'", "'\"'\"'") utils.exec_cmd(cmd + """ -e '{}' """.format(self._escape(query)))
utils.exec_cmd(cmd + """ -e '{}' """.format(query))
def create_user(self, name, password): def create_user(self, name, password):
"""Create a user.""" """Create a user."""
@@ -180,6 +210,12 @@ class MySQL(Database):
"GRANT ALL PRIVILEGES ON {}.* to '{}'@'localhost'" "GRANT ALL PRIVILEGES ON {}.* to '{}'@'localhost'"
.format(dbname, user)) .format(dbname, user))
def grant_right_on_table(self, dbname, table, user, right):
"""Grant specific right to user on table."""
query = "GRANT {} ON {}.{} TO '{}'@'%'".format(
right.upper(), dbname, table, user)
self._exec_query(query)
def load_sql_file(self, dbname, dbuser, dbpassword, path): def load_sql_file(self, dbname, dbuser, dbpassword, path):
"""Load SQL file.""" """Load SQL file."""
utils.exec_cmd( utils.exec_cmd(

View File

@@ -22,10 +22,14 @@ def get_pip_path(venv):
return binpath return binpath
def install_package(name, venv=None, upgrade=False, **kwargs): def install_package(name, venv=None, upgrade=False, binary=True, **kwargs):
"""Install a Python package using pip.""" """Install a Python package using pip."""
cmd = "{} install {}{}".format( cmd = "{} install{}{} {}".format(
get_pip_path(venv), " -U " if upgrade else "", name) get_pip_path(venv),
" -U" if upgrade else "",
" --no-binary :all:" if not binary else "",
name
)
utils.exec_cmd(cmd, **kwargs) utils.exec_cmd(cmd, **kwargs)
@@ -36,14 +40,35 @@ def install_packages(names, venv=None, upgrade=False, **kwargs):
utils.exec_cmd(cmd, **kwargs) utils.exec_cmd(cmd, **kwargs)
def setup_virtualenv(path, sudo_user=None): def install_package_from_repository(name, url, vcs="git", venv=None, **kwargs):
"""Install a Python package from its repository."""
if vcs == "git":
package.backend.install("git")
cmd = "{} install -e {}+{}#egg={}".format(
get_pip_path(venv), vcs, url, name)
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 python_version == 2:
python_binary = "python"
packages = ["python-virtualenv"] packages = ["python-virtualenv"]
if utils.dist_name() == "debian": if utils.dist_name() == "debian":
packages.append("virtualenv") packages.append("virtualenv")
else:
if utils.dist_name().startswith("centos"):
python_binary = "python36"
packages = ["python36"]
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):
if python_version == 2:
utils.exec_cmd("virtualenv {}".format(path)) utils.exec_cmd("virtualenv {}".format(path))
install_package("pip", venv=path, upgrade=True) else:
utils.exec_cmd("{} -m venv {}".format(python_binary, path))
install_packages(["pip", "setuptools"], venv=path, upgrade=True)

View File

@@ -1,5 +1,7 @@
"""Amavis related functions.""" """Amavis related functions."""
import platform
from .. import package from .. import package
from .. import utils from .. import utils
@@ -13,8 +15,15 @@ class Amavis(base.Installer):
appname = "amavis" appname = "amavis"
packages = { packages = {
"deb": ["libdbi-perl", "amavisd-new"], "deb": [
"rpm": ["amavisd-new"], "libdbi-perl", "amavisd-new", "arc", "arj", "cabextract",
"liblz4-tool", "lrzip", "lzop", "p7zip-full", "rpm2cpio",
"unrar-free", "ripole"
],
"rpm": [
"amavisd-new", "arj", "cabextract", "lz4", "lrzip",
"lzop", "p7zip", "unar", "unzoo"
],
} }
with_db = True with_db = True
@@ -43,6 +52,9 @@ class Amavis(base.Installer):
"""Additional packages.""" """Additional packages."""
packages = super(Amavis, self).get_packages() packages = super(Amavis, self).get_packages()
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
if platform.linux_distribution()[2] != "bionic":
# Quick fix
packages.append("zoo")
db_driver = "pg" if self.db_driver == "pgsql" else self.db_driver db_driver = "pg" if self.db_driver == "pgsql" else self.db_driver
return packages + ["libdbd-{}-perl".format(db_driver)] return packages + ["libdbd-{}-perl".format(db_driver)]
if self.db_driver == "pgsql": if self.db_driver == "pgsql":

View File

@@ -58,6 +58,10 @@ class Automx(base.Installer):
"future", "lxml", "ipaddress", "sqlalchemy", "python-memcached", "future", "lxml", "ipaddress", "sqlalchemy", "python-memcached",
"python-dateutil", "configparser" "python-dateutil", "configparser"
] ]
if self.dbengine == "postgres":
packages.append("psycopg2-binary")
else:
packages.append("mysqlclient")
python.install_packages(packages, self.venv_path, sudo_user=self.user) python.install_packages(packages, self.venv_path, sudo_user=self.user)
target = "{}/master.zip".format(self.home_dir) target = "{}/master.zip".format(self.home_dir)
if os.path.exists(target): if os.path.exists(target):

View File

@@ -14,7 +14,7 @@ class Installer(object):
appname = None appname = None
no_daemon = False no_daemon = False
daemon_name = None daemon_name = None
packages = [] packages = {}
with_user = False with_user = False
with_db = False with_db = False
config_files = [] config_files = []
@@ -22,6 +22,8 @@ class Installer(object):
def __init__(self, config): def __init__(self, config):
"""Get configuration.""" """Get configuration."""
self.config = config self.config = config
if self.config.has_section(self.appname):
self.app_config = dict(self.config.items(self.appname))
self.dbengine = self.config.get("database", "engine") self.dbengine = self.config.get("database", "engine")
# Used to install system packages # Used to install system packages
self.db_driver = ( self.db_driver = (
@@ -97,7 +99,7 @@ class Installer(object):
def get_packages(self): def get_packages(self):
"""Return the list of packages to install.""" """Return the list of packages to install."""
return self.packages[package.backend.FORMAT] return self.packages.get(package.backend.FORMAT, {})
def install_packages(self): def install_packages(self):
"""Install required packages.""" """Install required packages."""

View File

@@ -1,6 +1,7 @@
"""Dovecot related tools.""" """Dovecot related tools."""
import glob import glob
import os
import pwd import pwd
from .. import database from .. import database
@@ -25,7 +26,7 @@ class Dovecot(base.Installer):
} }
config_files = [ config_files = [
"dovecot.conf", "dovecot-dict-sql.conf.ext", "conf.d/10-ssl.conf", "dovecot.conf", "dovecot-dict-sql.conf.ext", "conf.d/10-ssl.conf",
"conf.d/20-lmtp.conf"] "conf.d/10-master.conf", "conf.d/20-lmtp.conf"]
with_user = True with_user = True
def get_config_files(self): def get_config_files(self):
@@ -35,6 +36,8 @@ class Dovecot(base.Installer):
.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"
.format(self.dbengine), .format(self.dbengine),
"postlogin-{}.sh=/usr/local/bin/postlogin.sh"
.format(self.dbengine),
] ]
def get_packages(self): def get_packages(self):
@@ -70,11 +73,15 @@ class Dovecot(base.Installer):
"db_driver": self.db_driver, "db_driver": self.db_driver,
"mailboxes_owner_uid": pw[2], "mailboxes_owner_uid": pw[2],
"mailboxes_owner_gid": pw[3], "mailboxes_owner_gid": pw[3],
"modoboa_user": self.config.get("modoboa", "user"),
"modoboa_dbname": self.config.get("modoboa", "dbname"), "modoboa_dbname": self.config.get("modoboa", "dbname"),
"modoboa_dbuser": self.config.get("modoboa", "dbuser"), "modoboa_dbuser": self.config.get("modoboa", "dbuser"),
"modoboa_dbpassword": self.config.get("modoboa", "dbpassword"), "modoboa_dbpassword": self.config.get("modoboa", "dbpassword"),
"protocols": protocols, "protocols": protocols,
"ssl_protocols": ssl_protocols "ssl_protocols": ssl_protocols,
"radicale_user": self.config.get("radicale", "user"),
"radicale_auth_socket_path": os.path.basename(
self.config.get("dovecot", "radicale_auth_socket_path"))
}) })
return context return context
@@ -95,6 +102,8 @@ class Dovecot(base.Installer):
) )
for f in glob.glob("{}/*".format(self.get_file_path("conf.d"))): for f in glob.glob("{}/*".format(self.get_file_path("conf.d"))):
utils.copy_file(f, "{}/conf.d".format(self.config_dir)) utils.copy_file(f, "{}/conf.d".format(self.config_dir))
# Make postlogin script executable
utils.exec_cmd("chmod +x /usr/local/bin/postlogin.sh")
def restart_daemon(self): def restart_daemon(self):
"""Restart daemon process. """Restart daemon process.

View File

@@ -0,0 +1,213 @@
-- Amavis 2.11.0 MySQL schema
-- Provided by Modoboa
-- Warning: foreign key creations are enabled
-- local users
CREATE TABLE users (
id int unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY, -- unique id
priority integer NOT NULL DEFAULT '7', -- sort field, 0 is low prior.
policy_id integer unsigned NOT NULL DEFAULT '1', -- JOINs with policy.id
email varbinary(255) NOT NULL UNIQUE,
fullname varchar(255) DEFAULT NULL -- not used by amavisd-new
-- local char(1) -- Y/N (optional field, see note further down)
);
-- any e-mail address (non- rfc2822-quoted), external or local,
-- used as senders in wblist
CREATE TABLE mailaddr (
id int unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
priority integer NOT NULL DEFAULT '7', -- 0 is low priority
email varbinary(255) NOT NULL UNIQUE
);
-- per-recipient whitelist and/or blacklist,
-- puts sender and recipient in relation wb (white or blacklisted sender)
CREATE TABLE wblist (
rid integer unsigned NOT NULL, -- recipient: users.id
sid integer unsigned NOT NULL, -- sender: mailaddr.id
wb varchar(10) NOT NULL, -- W or Y / B or N / space=neutral / score
PRIMARY KEY (rid,sid)
);
CREATE TABLE policy (
id int unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
-- 'id' this is the _only_ required field
policy_name varchar(32), -- not used by amavisd-new, a comment
virus_lover char(1) default NULL, -- Y/N
spam_lover char(1) default NULL, -- Y/N
unchecked_lover char(1) default NULL, -- Y/N
banned_files_lover char(1) default NULL, -- Y/N
bad_header_lover char(1) default NULL, -- Y/N
bypass_virus_checks char(1) default NULL, -- Y/N
bypass_spam_checks char(1) default NULL, -- Y/N
bypass_banned_checks char(1) default NULL, -- Y/N
bypass_header_checks char(1) default NULL, -- Y/N
virus_quarantine_to varchar(64) default NULL,
spam_quarantine_to varchar(64) default NULL,
banned_quarantine_to varchar(64) default NULL,
unchecked_quarantine_to varchar(64) default NULL,
bad_header_quarantine_to varchar(64) default NULL,
clean_quarantine_to varchar(64) default NULL,
archive_quarantine_to varchar(64) default NULL,
spam_tag_level float default NULL, -- higher score inserts spam info headers
spam_tag2_level float default NULL, -- inserts 'declared spam' header fields
spam_tag3_level float default NULL, -- inserts 'blatant spam' header fields
spam_kill_level float default NULL, -- higher score triggers evasive actions
-- e.g. reject/drop, quarantine, ...
-- (subject to final_spam_destiny setting)
spam_dsn_cutoff_level float default NULL,
spam_quarantine_cutoff_level float default NULL,
addr_extension_virus varchar(64) default NULL,
addr_extension_spam varchar(64) default NULL,
addr_extension_banned varchar(64) default NULL,
addr_extension_bad_header varchar(64) default NULL,
warnvirusrecip char(1) default NULL, -- Y/N
warnbannedrecip char(1) default NULL, -- Y/N
warnbadhrecip char(1) default NULL, -- Y/N
newvirus_admin varchar(64) default NULL,
virus_admin varchar(64) default NULL,
banned_admin varchar(64) default NULL,
bad_header_admin varchar(64) default NULL,
spam_admin varchar(64) default NULL,
spam_subject_tag varchar(64) default NULL,
spam_subject_tag2 varchar(64) default NULL,
spam_subject_tag3 varchar(64) default NULL,
message_size_limit integer default NULL, -- max size in bytes, 0 disable
banned_rulenames varchar(64) default NULL, -- comma-separated list of ...
-- names mapped through %banned_rules to actual banned_filename tables
disclaimer_options varchar(64) default NULL,
forward_method varchar(64) default NULL,
sa_userconf varchar(64) default NULL,
sa_username varchar(64) default NULL
);
-- R/W part of the dataset (optional)
-- May reside in the same or in a separate database as lookups database;
-- REQUIRES SUPPORT FOR TRANSACTIONS; specified in @storage_sql_dsn
--
-- MySQL note ( http://dev.mysql.com/doc/mysql/en/storage-engines.html ):
-- ENGINE is the preferred term, but cannot be used before MySQL 4.0.18.
-- TYPE is available beginning with MySQL 3.23.0, the first version of
-- MySQL for which multiple storage engines were available. If you omit
-- the ENGINE or TYPE option, the default storage engine is used.
-- By default this is MyISAM.
--
-- Please create additional indexes on keys when needed, or drop suggested
-- ones as appropriate to optimize queries needed by a management application.
-- See your database documentation for further optimization hints. With MySQL
-- see Chapter 15 of the reference manual. For example the chapter 15.17 says:
-- InnoDB does not keep an internal count of rows in a table. To process a
-- SELECT COUNT(*) FROM T statement, InnoDB must scan an index of the table,
-- which takes some time if the index is not entirely in the buffer pool.
--
-- Wayne Smith adds: When using MySQL with InnoDB one might want to
-- increase buffer size for both pool and log, and might also want
-- to change flush settings for a little better performance. Example:
-- innodb_buffer_pool_size = 384M
-- innodb_log_buffer_size = 8M
-- innodb_flush_log_at_trx_commit = 0
-- The big performance increase is the first two, the third just helps with
-- lowering disk activity. Consider also adjusting the key_buffer_size.
-- provide unique id for each e-mail address, avoids storing copies
CREATE TABLE maddr (
partition_tag integer DEFAULT 0, -- see $partition_tag
id bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
email varbinary(255) NOT NULL, -- full mail address
domain varchar(255) NOT NULL, -- only domain part of the email address
-- with subdomain fields in reverse
CONSTRAINT part_email UNIQUE (partition_tag,email)
) ENGINE=InnoDB;
-- information pertaining to each processed message as a whole;
-- NOTE: records with NULL msgs.content should be ignored by utilities,
-- as such records correspond to messages just being processes, or were lost
-- NOTE: instead of a character field time_iso, one might prefer:
-- time_iso TIMESTAMP NOT NULL DEFAULT 0,
-- but the following MUST then be set in amavisd.conf: $timestamp_fmt_mysql=1
CREATE TABLE msgs (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id varbinary(16) NOT NULL, -- long-term unique mail id, dflt 12 ch
secret_id varbinary(16) DEFAULT '', -- authorizes release of mail_id, 12 ch
am_id varchar(20) NOT NULL, -- id used in the log
time_num integer unsigned NOT NULL, -- rx_time: seconds since Unix epoch
time_iso char(16) NOT NULL, -- rx_time: ISO8601 UTC ascii time
sid bigint unsigned NOT NULL, -- sender: maddr.id
policy varchar(255) DEFAULT '', -- policy bank path (like macro %p)
client_addr varchar(255) DEFAULT '', -- SMTP client IP address (IPv4 or v6)
size integer unsigned NOT NULL, -- message size in bytes
originating char(1) DEFAULT ' ' NOT NULL, -- sender from inside or auth'd
content char(1), -- content type: V/B/U/S/Y/M/H/O/T/C
-- virus/banned/unchecked/spam(kill)/spammy(tag2)/
-- /bad-mime/bad-header/oversized/mta-err/clean
-- is NULL on partially processed mail
-- (prior to 2.7.0 the CC_SPAMMY was logged as 's', now 'Y' is used;
-- to avoid a need for case-insenstivity in queries)
quar_type char(1), -- quarantined as: ' '/F/Z/B/Q/M/L
-- none/file/zipfile/bsmtp/sql/
-- /mailbox(smtp)/mailbox(lmtp)
quar_loc varbinary(255) DEFAULT '', -- quarantine location (e.g. file)
dsn_sent char(1), -- was DSN sent? Y/N/q (q=quenched)
spam_level float, -- SA spam level (no boosts)
message_id varchar(255) DEFAULT '', -- mail Message-ID header field
from_addr varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT '',
-- mail From header field, UTF8
subject varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT '',
-- mail Subject header field, UTF8
host varchar(255) NOT NULL, -- hostname where amavisd is running
PRIMARY KEY (partition_tag,mail_id),
FOREIGN KEY (sid) REFERENCES maddr(id) ON DELETE RESTRICT
) ENGINE=InnoDB;
CREATE INDEX msgs_idx_sid ON msgs (sid);
CREATE INDEX msgs_idx_mess_id ON msgs (message_id); -- useful with pen pals
CREATE INDEX msgs_idx_time_num ON msgs (time_num);
-- alternatively when purging based on time_iso (instead of msgs_idx_time_num):
CREATE INDEX msgs_idx_time_iso ON msgs (time_iso);
-- When using FOREIGN KEY contraints, InnoDB requires index on a field
-- (an the field must be the first field in the index). Hence create it:
CREATE INDEX msgs_idx_mail_id ON msgs (mail_id);
-- per-recipient information related to each processed message;
-- NOTE: records in msgrcpt without corresponding msgs.mail_id record are
-- orphaned and should be ignored and eventually deleted by external utilities
CREATE TABLE msgrcpt (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id varbinary(16) NOT NULL, -- (must allow duplicates)
rseqnum integer DEFAULT 0 NOT NULL, -- recip's enumeration within msg
rid bigint unsigned NOT NULL, -- recipient: maddr.id (dupl. allowed)
is_local char(1) DEFAULT ' ' NOT NULL, -- recip is: Y=local, N=foreign
content char(1) DEFAULT ' ' NOT NULL, -- content type V/B/U/S/Y/M/H/O/T/C
ds char(1) NOT NULL, -- delivery status: P/R/B/D/T
-- pass/reject/bounce/discard/tempfail
rs char(1) NOT NULL, -- release status: initialized to ' '
bl char(1) DEFAULT ' ', -- sender blacklisted by this recip
wl char(1) DEFAULT ' ', -- sender whitelisted by this recip
bspam_level float, -- per-recipient (total) spam level
smtp_resp varchar(255) DEFAULT '', -- SMTP response given to MTA
PRIMARY KEY (partition_tag,mail_id,rseqnum),
FOREIGN KEY (rid) REFERENCES maddr(id) ON DELETE RESTRICT,
FOREIGN KEY (mail_id) REFERENCES msgs(mail_id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX msgrcpt_idx_mail_id ON msgrcpt (mail_id);
CREATE INDEX msgrcpt_idx_rid ON msgrcpt (rid);
-- Additional index on rs since Modoboa uses it to filter its quarantine
CREATE INDEX msgrcpt_idx_rs ON msgrcpt (rs);
-- mail quarantine in SQL, enabled by $*_quarantine_method='sql:'
-- NOTE: records in quarantine without corresponding msgs.mail_id record are
-- orphaned and should be ignored and eventually deleted by external utilities
CREATE TABLE quarantine (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id varbinary(16) NOT NULL, -- long-term unique mail id
chunk_ind integer unsigned NOT NULL, -- chunk number, starting with 1
mail_text blob NOT NULL, -- store mail as chunks of octets
PRIMARY KEY (partition_tag,mail_id,chunk_ind),
FOREIGN KEY (mail_id) REFERENCES msgs(mail_id) ON DELETE CASCADE
) ENGINE=InnoDB;

View File

@@ -173,6 +173,8 @@ CREATE TABLE msgrcpt (
); );
CREATE INDEX msgrcpt_idx_mail_id ON msgrcpt (mail_id); CREATE INDEX msgrcpt_idx_mail_id ON msgrcpt (mail_id);
CREATE INDEX msgrcpt_idx_rid ON msgrcpt (rid); CREATE INDEX msgrcpt_idx_rid ON msgrcpt (rid);
-- Additional index on rs since Modoboa uses it to filter its quarantine
CREATE INDEX msgrcpt_idx_rs ON msgrcpt (rs);
-- mail quarantine in SQL, enabled by $*_quarantine_method='sql:' -- mail quarantine in SQL, enabled by $*_quarantine_method='sql:'
-- NOTE: records in quarantine without corresponding msgs.mail_id record are -- NOTE: records in quarantine without corresponding msgs.mail_id record are

View File

@@ -0,0 +1,189 @@
CREATE TABLE policy (
id serial PRIMARY KEY, -- 'id' is the _only_ required field
policy_name varchar(32), -- not used by amavisd-new, a comment
virus_lover char(1) default NULL, -- Y/N
spam_lover char(1) default NULL, -- Y/N
unchecked_lover char(1) default NULL, -- Y/N
banned_files_lover char(1) default NULL, -- Y/N
bad_header_lover char(1) default NULL, -- Y/N
bypass_virus_checks char(1) default NULL, -- Y/N
bypass_spam_checks char(1) default NULL, -- Y/N
bypass_banned_checks char(1) default NULL, -- Y/N
bypass_header_checks char(1) default NULL, -- Y/N
virus_quarantine_to varchar(64) default NULL,
spam_quarantine_to varchar(64) default NULL,
banned_quarantine_to varchar(64) default NULL,
unchecked_quarantine_to varchar(64) default NULL,
bad_header_quarantine_to varchar(64) default NULL,
clean_quarantine_to varchar(64) default NULL,
archive_quarantine_to varchar(64) default NULL,
spam_tag_level real default NULL, -- higher score inserts spam info headers
spam_tag2_level real default NULL, -- inserts 'declared spam' header fields
spam_tag3_level real default NULL, -- inserts 'blatant spam' header fields
spam_kill_level real default NULL, -- higher score triggers evasive actions
-- e.g. reject/drop, quarantine, ...
-- (subject to final_spam_destiny setting)
spam_dsn_cutoff_level real default NULL,
spam_quarantine_cutoff_level real default NULL,
addr_extension_virus varchar(64) default NULL,
addr_extension_spam varchar(64) default NULL,
addr_extension_banned varchar(64) default NULL,
addr_extension_bad_header varchar(64) default NULL,
warnvirusrecip char(1) default NULL, -- Y/N
warnbannedrecip char(1) default NULL, -- Y/N
warnbadhrecip char(1) default NULL, -- Y/N
newvirus_admin varchar(64) default NULL,
virus_admin varchar(64) default NULL,
banned_admin varchar(64) default NULL,
bad_header_admin varchar(64) default NULL,
spam_admin varchar(64) default NULL,
spam_subject_tag varchar(64) default NULL,
spam_subject_tag2 varchar(64) default NULL,
spam_subject_tag3 varchar(64) default NULL,
message_size_limit integer default NULL, -- max size in bytes, 0 disable
banned_rulenames varchar(64) default NULL, -- comma-separated list of ...
-- names mapped through %banned_rules to actual banned_filename tables
disclaimer_options varchar(64) default NULL,
forward_method varchar(64) default NULL,
sa_userconf varchar(64) default NULL,
sa_username varchar(64) default NULL
);
-- local users
CREATE TABLE users (
id serial PRIMARY KEY, -- unique id
priority integer NOT NULL DEFAULT 7, -- sort field, 0 is low prior.
policy_id integer NOT NULL DEFAULT 1 CHECK (policy_id >= 0) REFERENCES policy(id),
email bytea NOT NULL UNIQUE, -- email address, non-rfc2822-quoted
fullname varchar(255) DEFAULT NULL -- not used by amavisd-new
-- local char(1) -- Y/N (optional, see SQL section in README.lookups)
);
-- any e-mail address (non- rfc2822-quoted), external or local,
-- used as senders in wblist
CREATE TABLE mailaddr (
id serial PRIMARY KEY,
priority integer NOT NULL DEFAULT 9, -- 0 is low priority
email bytea NOT NULL UNIQUE
);
-- per-recipient whitelist and/or blacklist,
-- puts sender and recipient in relation wb (white or blacklisted sender)
CREATE TABLE wblist (
rid integer NOT NULL CHECK (rid >= 0) REFERENCES users(id),
sid integer NOT NULL CHECK (sid >= 0) REFERENCES mailaddr(id),
wb varchar(10) NOT NULL, -- W or Y / B or N / space=neutral / score
PRIMARY KEY (rid,sid)
);
-- grant usage rights:
GRANT select ON policy TO amavis;
GRANT select ON users TO amavis;
GRANT select ON mailaddr TO amavis;
GRANT select ON wblist TO amavis;
-- R/W part of the dataset (optional)
-- May reside in the same or in a separate database as lookups database;
-- REQUIRES SUPPORT FOR TRANSACTIONS; specified in @storage_sql_dsn
--
-- Please create additional indexes on keys when needed, or drop suggested
-- ones as appropriate to optimize queries needed by a management application.
-- See your database documentation for further optimization hints.
-- provide unique id for each e-mail address, avoids storing copies
CREATE TABLE maddr (
id serial PRIMARY KEY,
partition_tag integer DEFAULT 0, -- see $partition_tag
email bytea NOT NULL, -- full e-mail address
domain varchar(255) NOT NULL, -- only domain part of the email address
-- with subdomain fields in reverse
CONSTRAINT part_email UNIQUE (partition_tag,email)
);
-- information pertaining to each processed message as a whole;
-- NOTE: records with a NULL msgs.content should be ignored by utilities,
-- as such records correspond to messages just being processed, or were lost
CREATE TABLE msgs (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id bytea NOT NULL, -- long-term unique mail id, dflt 12 ch
secret_id bytea DEFAULT '', -- authorizes release of mail_id, 12 ch
am_id varchar(20) NOT NULL, -- id used in the log
time_num integer NOT NULL CHECK (time_num >= 0),
-- rx_time: seconds since Unix epoch
time_iso timestamp WITH TIME ZONE NOT NULL,-- rx_time: ISO8601 UTC ascii time
sid integer NOT NULL CHECK (sid >= 0), -- sender: maddr.id
policy varchar(255) DEFAULT '', -- policy bank path (like macro %p)
client_addr varchar(255) DEFAULT '', -- SMTP client IP address (IPv4 or v6)
size integer NOT NULL CHECK (size >= 0), -- message size in bytes
originating char(1) DEFAULT ' ' NOT NULL, -- sender from inside or auth'd
content char(1), -- content type: V/B/U/S/Y/M/H/O/T/C
-- virus/banned/unchecked/spam(kill)/spammy(tag2)/
-- /bad-mime/bad-header/oversized/mta-err/clean
-- is NULL on partially processed mail
-- (prior to 2.7.0 the CC_SPAMMY was logged as 's', now 'Y' is used;
--- to avoid a need for case-insenstivity in queries)
quar_type char(1), -- quarantined as: ' '/F/Z/B/Q/M/L
-- none/file/zipfile/bsmtp/sql/
-- /mailbox(smtp)/mailbox(lmtp)
quar_loc varchar(255) DEFAULT '', -- quarantine location (e.g. file)
dsn_sent char(1), -- was DSN sent? Y/N/q (q=quenched)
spam_level real, -- SA spam level (no boosts)
message_id varchar(255) DEFAULT '', -- mail Message-ID header field
from_addr varchar(255) DEFAULT '', -- mail From header field, UTF8
subject varchar(255) DEFAULT '', -- mail Subject header field, UTF8
host varchar(255) NOT NULL, -- hostname where amavisd is running
CONSTRAINT msgs_partition_mail UNIQUE (partition_tag,mail_id),
PRIMARY KEY (partition_tag,mail_id)
--FOREIGN KEY (sid) REFERENCES maddr(id) ON DELETE RESTRICT
);
CREATE INDEX msgs_idx_sid ON msgs (sid);
CREATE INDEX msgs_idx_mess_id ON msgs (message_id); -- useful with pen pals
CREATE INDEX msgs_idx_time_iso ON msgs (time_iso);
CREATE INDEX msgs_idx_time_num ON msgs (time_num); -- optional
-- per-recipient information related to each processed message;
-- NOTE: records in msgrcpt without corresponding msgs.mail_id record are
-- orphaned and should be ignored and eventually deleted by external utilities
CREATE TABLE msgrcpt (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id bytea NOT NULL, -- (must allow duplicates)
rseqnum integer DEFAULT 0 NOT NULL, -- recip's enumeration within msg
rid integer NOT NULL, -- recipient: maddr.id (duplicates allowed)
is_local char(1) DEFAULT ' ' NOT NULL, -- recip is: Y=local, N=foreign
content char(1) DEFAULT ' ' NOT NULL, -- content type V/B/U/S/Y/M/H/O/T/C
ds char(1) NOT NULL, -- delivery status: P/R/B/D/T
-- pass/reject/bounce/discard/tempfail
rs char(1) NOT NULL, -- release status: initialized to ' '
bl char(1) DEFAULT ' ', -- sender blacklisted by this recip
wl char(1) DEFAULT ' ', -- sender whitelisted by this recip
bspam_level real, -- per-recipient (total) spam level
smtp_resp varchar(255) DEFAULT '', -- SMTP response given to MTA
CONSTRAINT msgrcpt_partition_mail_rseq UNIQUE (partition_tag,mail_id,rseqnum),
PRIMARY KEY (partition_tag,mail_id,rseqnum)
--FOREIGN KEY (rid) REFERENCES maddr(id) ON DELETE RESTRICT,
--FOREIGN KEY (mail_id) REFERENCES msgs(mail_id) ON DELETE CASCADE
);
CREATE INDEX msgrcpt_idx_mail_id ON msgrcpt (mail_id);
CREATE INDEX msgrcpt_idx_rid ON msgrcpt (rid);
-- Additional index on rs since Modoboa uses it to filter its quarantine
CREATE INDEX msgrcpt_idx_rs ON msgrcpt (rs);
-- mail quarantine in SQL, enabled by $*_quarantine_method='sql:'
-- NOTE: records in quarantine without corresponding msgs.mail_id record are
-- orphaned and should be ignored and eventually deleted by external utilities
CREATE TABLE quarantine (
partition_tag integer DEFAULT 0, -- see $partition_tag
mail_id bytea NOT NULL, -- long-term unique mail id
chunk_ind integer NOT NULL CHECK (chunk_ind >= 0), -- chunk number, 1..
mail_text bytea NOT NULL, -- store mail as chunks of octects
PRIMARY KEY (partition_tag,mail_id,chunk_ind)
--FOREIGN KEY (mail_id) REFERENCES msgs(mail_id) ON DELETE CASCADE
);

View File

@@ -10,7 +10,7 @@ rate_limit_exception_networks = 127.0.0.0/8, ::1/128
[global] [global]
backend = sql backend = sql
actions = settings action = settings
account_type = email account_type = email
host = %sql_dsn host = %sql_dsn
query = %sql_query query = %sql_query

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 auth_mechanisms = plain login
## ##
## Password and user databases ## Password and user databases

View File

@@ -71,11 +71,22 @@ service imap {
# Max. number of IMAP processes (connections) # Max. number of IMAP processes (connections)
#process_limit = 1024 #process_limit = 1024
executable = imap postlogin
} }
service pop3 { service pop3 {
# Max. number of POP3 processes (connections) # Max. number of POP3 processes (connections)
#process_limit = 1024 #process_limit = 1024
executable = pop3 postlogin
}
service postlogin {
executable = script-login /usr/local/bin/postlogin.sh
user = %modoboa_user
unix_listener postlogin {
}
} }
service auth { service auth {
@@ -105,6 +116,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

@@ -123,7 +123,7 @@ connect = host=%dbhost dbname=%modoboa_dbname user=%modoboa_dbuser password=%mod
#user_query = \ #user_query = \
# SELECT home, uid, gid \ # SELECT home, uid, gid \
# FROM users WHERE username = '%%n' AND domain = '%%d' # FROM users WHERE username = '%%n' AND domain = '%%d'
user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, CONCAT('*:bytes=', mb.quota, 'M') AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE mb.address='%%n' AND dom.name='%%d' AND u.is_active=1 AND dom.enabled=1 user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, CONCAT('*:bytes=', mb.quota, 'M') AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE mb.address='%%n' AND dom.name='%%d'
# If you wish to avoid two SQL lookups (passdb + userdb), you can use # If you wish to avoid two SQL lookups (passdb + userdb), you can use
# userdb prefetch instead of userdb sql in dovecot.conf. In that case you'll # userdb prefetch instead of userdb sql in dovecot.conf. In that case you'll

View File

@@ -123,7 +123,7 @@ connect = host=%dbhost dbname=%modoboa_dbname user=%modoboa_dbuser password=%mod
#user_query = \ #user_query = \
# SELECT home, uid, gid \ # SELECT home, uid, gid \
# FROM users WHERE username = '%%n' AND domain = '%%d' # FROM users WHERE username = '%%n' AND domain = '%%d'
user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, '*:bytes=' || mb.quota || 'M' AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE mb.address='%%n' AND dom.name='%%d' AND u.is_active AND dom.enabled user_query = SELECT '%{home_dir}/%%d/%%n' AS home, %mailboxes_owner_uid as uid, %mailboxes_owner_gid as gid, '*:bytes=' || mb.quota || 'M' AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN core_user u ON u.id=mb.user_id WHERE mb.address='%%n' AND dom.name='%%d'
# If you wish to avoid two SQL lookups (passdb + userdb), you can use # If you wish to avoid two SQL lookups (passdb + userdb), you can use
# userdb prefetch instead of userdb sql in dovecot.conf. In that case you'll # userdb prefetch instead of userdb sql in dovecot.conf. In that case you'll

View File

@@ -0,0 +1,7 @@
#!/bin/sh
DBNAME=%modoboa_dbname DBUSER=%modoboa_dbuser DBPASSWORD=%modoboa_dbpassword
echo "UPDATE core_user SET last_login=now() WHERE username='$USER'" | mysql -u $DBUSER -p$DBPASSWORD $DBNAME
exec "$@"

View File

@@ -0,0 +1,7 @@
#!/bin/sh
PATH="/usr/bin:/usr/local/bin:/bin"
psql -c "UPDATE core_user SET last_login=now() WHERE username='$USER'" > /dev/null
exec "$@"

View File

@@ -17,10 +17,11 @@ INSTANCE=%{instance_path}
%{amavis_enabled}0 0 * * * root $PYTHON $INSTANCE/manage.py qcleanup %{amavis_enabled}0 0 * * * root $PYTHON $INSTANCE/manage.py qcleanup
# Notifications about pending release requests # Notifications about pending release requests
%{amavis_enabled}0 12 * * * root $PYTHON $INSTANCE/manage.py amnotify --baseurl='http://%{hostname}' %{amavis_enabled}0 12 * * * root $PYTHON $INSTANCE/manage.py amnotify
# Logs parsing # Logs parsing
*/5 * * * * root $PYTHON $INSTANCE/manage.py logparser &> /dev/null */5 * * * * root $PYTHON $INSTANCE/manage.py logparser &> /dev/null
0 * * * * root $PYTHON $INSTANCE/manage.py update_statistics
# Radicale rights file # Radicale rights file
%{radicale_enabled}*/2 * * * * root $PYTHON $INSTANCE/manage.py generate_rights %{radicale_enabled}*/2 * * * * root $PYTHON $INSTANCE/manage.py generate_rights
@@ -30,3 +31,6 @@ INSTANCE=%{instance_path}
# Public API communication # Public API communication
0 * * * * root $PYTHON $INSTANCE/manage.py communicate_with_public_api 0 * * * * root $PYTHON $INSTANCE/manage.py communicate_with_public_api
# Generate DKIM keys (they will belong to the user running this job)
%{opendkim_enabled}* * * * * %{opendkim_user} $PYTHON $INSTANCE/manage.py modo manage_dkim_keys

View File

@@ -1 +1 @@
%{user} ALL=(%{dovecot_mailboxes_owner}) NOPASSWD: /usr/bin/doveadm %{sudo_user} ALL=(%{dovecot_mailboxes_owner}) NOPASSWD: /usr/bin/doveadm

View File

@@ -42,4 +42,5 @@ server {
uwsgi_param UWSGI_SCRIPT instance.wsgi:application; uwsgi_param UWSGI_SCRIPT instance.wsgi:application;
uwsgi_pass modoboa; uwsgi_pass modoboa;
} }
%{extra_config}
} }

View File

@@ -0,0 +1,5 @@
CREATE OR REPLACE VIEW dkim AS (
SELECT id, name as domain_name, dkim_private_key_path AS private_key_path,
dkim_key_selector AS selector
FROM admin_domain WHERE enable_dkim=1
);

View File

@@ -0,0 +1,5 @@
CREATE OR REPLACE VIEW dkim AS (
SELECT id, name as domain_name, dkim_private_key_path AS private_key_path,
dkim_key_selector AS selector
FROM admin_domain WHERE enable_dkim
);

View File

@@ -0,0 +1,91 @@
# This is a basic configuration that can easily be adapted to suit a standard
# installation. For more advanced options, see opendkim.conf(5) and/or
# /usr/share/doc/opendkim/examples/opendkim.conf.sample.
# Log to syslog
Syslog yes
SyslogSuccess Yes
LogWhy Yes
LogResults Yes
# Required to use local socket with MTAs that access the socket as a non-
# privileged user (e.g. Postfix)
UMask 007
# Sign for example.com with key in /etc/dkimkeys/dkim.key using
# selector '2007' (e.g. 2007._domainkey.example.com)
#Domain example.com
#KeyFile /etc/dkimkeys/dkim.key
#Selector 2007
KeyTable dsn:%{db_driver}://%{db_user}:%{db_password}@%{dbhost}/%{db_name}/table=dkim?keycol=id?datacol=domain_name,selector,private_key_path
SigningTable dsn:%db_driver://%{db_user}:%{db_password}@%{dbhost}/%{db_name}/table=dkim?keycol=domain_name?datacol=id
# Commonly-used options; the commented-out versions show the defaults.
#Canonicalization simple
#Mode sv
SubDomains yes
Canonicalization relaxed/relaxed
# Socket smtp://localhost
#
# ## Socket socketspec
# ##
# ## Names the socket where this filter should listen for milter connections
# ## from the MTA. Required. Should be in one of these forms:
# ##
# ## inet:port@address to listen on a specific interface
# ## inet:port to listen on all interfaces
# ## local:/path/to/socket to listen on a UNIX domain socket
#
Socket inet:%{port}@localhost
#Socket local:/var/run/opendkim/opendkim.sock
## PidFile filename
### default (none)
###
### Name of the file where the filter should write its pid before beginning
### normal operations.
#
PidFile /var/run/opendkim/opendkim.pid
# Always oversign From (sign using actual From and a null From to prevent
# malicious signatures header fields (From and/or others) between the signer
# and the verifier. From is oversigned by default in the Debian pacakge
# because it is often the identity key used by reputation systems and thus
# somewhat security sensitive.
OversignHeaders From
## ResolverConfiguration filename
## default (none)
##
## Specifies a configuration file to be passed to the Unbound library that
## performs DNS queries applying the DNSSEC protocol. See the Unbound
## documentation at http://unbound.net for the expected content of this file.
## The results of using this and the TrustAnchorFile setting at the same
## time are undefined.
## In Debian, /etc/unbound/unbound.conf is shipped as part of the Suggested
## unbound package
# ResolverConfiguration /etc/unbound/unbound.conf
## TrustAnchorFile filename
## default (none)
##
## Specifies a file from which trust anchor data should be read when doing
## DNS queries and applying the DNSSEC protocol. See the Unbound documentation
## at http://unbound.net for the expected format of this file.
# TrustAnchorFile /usr/share/dns/root.key
## Userid userid
### default (none)
###
### Change to user "userid" before starting normal operation? May include
### a group ID as well, separated from the userid by a colon.
#
UserID %{user}
ExternalIgnoreList /etc/opendkim.hosts
InternalHosts /etc/opendkim.hosts

View File

@@ -0,0 +1,3 @@
127.0.0.1
::1
localhost

View File

@@ -2,7 +2,7 @@ inet_interfaces = all
inet_protocols = ipv4 inet_protocols = ipv4
myhostname = %hostname myhostname = %hostname
myorigin = $myhostname myorigin = $myhostname
mydestination = mydestination = $myhostname
mynetworks = 127.0.0.0/8 mynetworks = 127.0.0.0/8
smtpd_banner = $myhostname ESMTP smtpd_banner = $myhostname ESMTP
biff = no biff = no
@@ -28,20 +28,18 @@ proxy_read_maps =
proxy:%{db_driver}:/etc/postfix/sql-domain-aliases.cf proxy:%{db_driver}:/etc/postfix/sql-domain-aliases.cf
proxy:%{db_driver}:/etc/postfix/sql-aliases.cf proxy:%{db_driver}:/etc/postfix/sql-aliases.cf
proxy:%{db_driver}:/etc/postfix/sql-relaydomains.cf proxy:%{db_driver}:/etc/postfix/sql-relaydomains.cf
proxy:%{db_driver}:/etc/postfix/sql-relaydomains-transport.cf
proxy:%{db_driver}:/etc/postfix/sql-relaydomain-aliases-transport.cf
proxy:%{db_driver}:/etc/postfix/sql-autoreplies-transport.cf proxy:%{db_driver}:/etc/postfix/sql-autoreplies-transport.cf
proxy:%{db_driver}:/etc/postfix/sql-maintain.cf proxy:%{db_driver}:/etc/postfix/sql-maintain.cf
proxy:%{db_driver}:/etc/postfix/sql-relay-recipient-verification.cf proxy:%{db_driver}:/etc/postfix/sql-relay-recipient-verification.cf
proxy:%{db_driver}:/etc/postfix/sql-sender-login-mailboxes.cf proxy:%{db_driver}:/etc/postfix/sql-sender-login-map.cf
proxy:%{db_driver}:/etc/postfix/sql-sender-login-aliases.cf
proxy:%{db_driver}:/etc/postfix/sql-sender-login-mailboxes-extra.cf
proxy:%{db_driver}:/etc/postfix/sql-spliteddomains-transport.cf proxy:%{db_driver}:/etc/postfix/sql-spliteddomains-transport.cf
proxy:%{db_driver}:/etc/postfix/sql-transport.cf
## TLS settings ## TLS settings
# #
smtpd_use_tls = yes smtpd_use_tls = yes
smtpd_tls_auth_only = no smtpd_tls_auth_only = no
smtpd_tls_CApath = /etc/ssl/certs
smtpd_tls_key_file = %tls_key_file smtpd_tls_key_file = %tls_key_file
smtpd_tls_cert_file = %tls_cert_file smtpd_tls_cert_file = %tls_cert_file
smtpd_tls_dh1024_param_file = ${config_directory}/dh2048.pem smtpd_tls_dh1024_param_file = ${config_directory}/dh2048.pem
@@ -61,6 +59,7 @@ smtpd_tls_exclude_ciphers = aNULL, MD5 , DES, ADH, RC4, PSD, SRP, 3DES, eNULL
smtpd_tls_eecdh_grade = strong smtpd_tls_eecdh_grade = strong
# Use TLS if this is supported by the remote SMTP server, otherwise use plaintext. # Use TLS if this is supported by the remote SMTP server, otherwise use plaintext.
smtp_tls_CApath = /etc/ssl/certs
smtp_tls_security_level = may smtp_tls_security_level = may
smtp_tls_loglevel = 1 smtp_tls_loglevel = 1
smtp_tls_exclude_ciphers = EXPORT, LOW smtp_tls_exclude_ciphers = EXPORT, LOW
@@ -79,8 +78,8 @@ virtual_alias_maps =
relay_domains = relay_domains =
proxy:%{db_driver}:/etc/postfix/sql-relaydomains.cf proxy:%{db_driver}:/etc/postfix/sql-relaydomains.cf
transport_maps = transport_maps =
proxy:%{db_driver}:/etc/postfix/sql-transport.cf
proxy:%{db_driver}:/etc/postfix/sql-spliteddomains-transport.cf proxy:%{db_driver}:/etc/postfix/sql-spliteddomains-transport.cf
proxy:%{db_driver}:/etc/postfix/sql-relaydomains-transport.cf
proxy:%{db_driver}:/etc/postfix/sql-autoreplies-transport.cf proxy:%{db_driver}:/etc/postfix/sql-autoreplies-transport.cf
## SASL authentication through Dovecot ## SASL authentication through Dovecot
@@ -112,11 +111,15 @@ strict_rfc821_envelopes = yes
%{dovecot_enabled} $lmtp_sasl_auth_cache_name %{dovecot_enabled} $lmtp_sasl_auth_cache_name
%{dovecot_enabled} $address_verify_map %{dovecot_enabled} $address_verify_map
# OpenDKIM setup
%{opendkim_enabled}smtpd_milters = inet:127.0.0.1:%{opendkim_port}
%{opendkim_enabled}non_smtpd_milters = inet:127.0.0.1:%{opendkim_port}
%{opendkim_enabled}milter_default_action = accept
%{opendkim_enabled}milter_content_timeout = 30s
# List of authorized senders # List of authorized senders
smtpd_sender_login_maps = smtpd_sender_login_maps =
proxy:%{db_driver}:/etc/postfix/sql-sender-login-mailboxes.cf proxy:%{db_driver}:/etc/postfix/sql-sender-login-map.cf
proxy:%{db_driver}:/etc/postfix/sql-sender-login-aliases.cf
proxy:%{db_driver}:/etc/postfix/sql-sender-login-mailboxes-extra.cf
# Recipient restriction rules # Recipient restriction rules
smtpd_recipient_restrictions = smtpd_recipient_restrictions =
@@ -135,6 +138,7 @@ smtpd_recipient_restrictions =
# #
postscreen_access_list = postscreen_access_list =
permit_mynetworks permit_mynetworks
cidr:/etc/postfix/postscreen_spf_whitelist.cidr
postscreen_blacklist_action = enforce postscreen_blacklist_action = enforce
# Use some DNSBL # Use some DNSBL

View File

@@ -0,0 +1,9 @@
#
# Postwhite specific cron jobs
#
# Update Postscreen Whitelists
@daily root /usr/local/bin/postwhite/postwhite > /dev/null 2>&1
# Update Yahoo! IPs for Postscreen Whitelists
@weekly root /usr/local/bin/postwhite/scrape_yahoo > /dev/null 2>&1

View File

@@ -0,0 +1,161 @@
# -*- mode: conf -*-
# vim:ft=cfg
# Config file for Radicale - A simple calendar server
#
# Place it into /etc/radicale/config (global)
# or ~/.config/radicale/config (user)
#
# The current values are the default ones
[server]
# CalDAV server hostnames separated by a comma
# IPv4 syntax: address:port
# IPv6 syntax: [address]:port
# For example: 0.0.0.0:9999, [::]:9999
#hosts = 127.0.0.1:5232
# Daemon flag
#daemon = False
# File storing the PID in daemon mode
#pid =
# Max parallel connections
#max_connections = 20
# Max size of request body (bytes)
#max_content_length = 10000000
# Socket timeout (seconds)
#timeout = 10
# SSL flag, enable HTTPS protocol
#ssl = False
# SSL certificate path
#certificate = /etc/ssl/radicale.cert.pem
# SSL private key
#key = /etc/ssl/radicale.key.pem
# CA certificate for validating clients. This can be used to secure
# TCP traffic between Radicale and a reverse proxy
#certificate_authority =
# SSL Protocol used. See python's ssl module for available values
#protocol = PROTOCOL_TLSv1_2
# Available ciphers. See python's ssl module for available ciphers
#ciphers =
# Reverse DNS to resolve client address in logs
#dns_lookup = True
# Message displayed in the client when a password is needed
#realm = Radicale - Password Required
[encoding]
# Encoding for responding requests
#request = utf-8
# Encoding for storing local collections
#stock = utf-8
[auth]
# Authentication method
# Value: none | htpasswd | remote_user | http_x_remote_user
type = radicale_dovecot_auth
# Htpasswd filename
# htpasswd_filename = users
# Htpasswd encryption method
# Value: plain | sha1 | ssha | crypt | bcrypt | md5
# Only bcrypt can be considered secure.
# bcrypt and md5 require the passlib library to be installed.
# htpasswd_encryption = plain
# Incorrect authentication delay (seconds)
#delay = 1
auth_socket = %{auth_socket_path}
[rights]
# Rights backend
# Value: none | authenticated | owner_only | owner_write | from_file
type = from_file
# File for rights management from_file
file = %{config_dir}/rights
[storage]
# Storage backend
# Value: multifilesystem
type = radicale_storage_by_index
radicale_storage_by_index_fields = dtstart, dtend, uid, summary
# Folder for storing local collections, created if not present
filesystem_folder = %{home_dir}/collections
# Lock the storage. Never start multiple instances of Radicale or edit the
# storage externally while Radicale is running if disabled.
#filesystem_locking = True
# Sync all changes to disk during requests. (This can impair performance.)
# Disabling it increases the risk of data loss, when the system crashes or
# power fails!
#filesystem_fsync = True
# Delete sync token that are older (seconds)
#max_sync_token_age = 2592000
# Close the lock file when no more clients are waiting.
# This option is not very useful in general, but on Windows files that are
# opened cannot be deleted.
#filesystem_close_lock_file = False
# Command that is run after changes to storage
# Example: ([ -d .git ] || git init) && git add -A && (git diff --cached --quiet || git commit -m "Changes by "%%(user)s)
#hook =
[web]
# Web interface backend
# Value: none | internal
type = none
[logging]
# Logging configuration file
# If no config is given, simple information is printed on the standard output
# For more information about the syntax of the configuration file, see:
# http://docs.python.org/library/logging.config.html
#config = /etc/radicale/logging
# Set the default logging level to debug
debug = False
# Store all environment variables (including those set in the shell)
#full_environment = False
# Don't include passwords in logs
#mask_passwords = True
[headers]
# Additional HTTP headers
#Access-Control-Allow-Origin = *

View File

@@ -0,0 +1,8 @@
[program:radicale]
autostart=true
autorestart=true
command=%{venv_path}/bin/radicale -C %{config_dir}/config
directory=%{home_dir}
redirect_stderr=true
user=%{user}
numprocs=1

View File

@@ -75,7 +75,3 @@ loadplugin Mail::SpamAssassin::Plugin::MIMEHeader
# ReplaceTags # ReplaceTags
# #
loadplugin Mail::SpamAssassin::Plugin::ReplaceTags loadplugin Mail::SpamAssassin::Plugin::ReplaceTags
# DCC - perform DCC message checks.
#
loadplugin Mail::SpamAssassin::Plugin::DCC

View File

@@ -7,6 +7,7 @@ import shutil
import stat import stat
import sys import sys
from .. import compatibility_matrix
from .. import package from .. import package
from .. import python from .. import python
from .. import utils from .. import utils
@@ -49,18 +50,55 @@ class Modoboa(base.Installer):
self.amavis_enabled = True self.amavis_enabled = True
else: else:
self.extensions.remove("modoboa-amavis") self.extensions.remove("modoboa-amavis")
if "modoboa-radicale" in self.extensions:
if not self.config.getboolean("radicale", "enabled"):
self.extensions.remove("modoboa-radicale")
def is_extension_ok_for_version(self, extension, 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)
min_version = compatibility_matrix.EXTENSIONS_AVAILABILITY[extension]
min_version = utils.convert_version_to_int(min_version)
return version >= min_version
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)
packages = ["modoboa", "rrdtool"] packages = ["rrdtool"]
if self.dbengine == "postgres": version = self.config.get("modoboa", "version")
packages.append("psycopg2") if version == "latest":
modoboa_package = "modoboa"
packages += self.extensions
else: else:
packages.append("MYSQL-Python") matrix = compatibility_matrix.COMPATIBILITY_MATRIX[version]
modoboa_package = "modoboa=={}".format(version)
for extension in list(self.extensions):
if not self.is_extension_ok_for_version(extension, version):
self.extensions.remove(extension)
continue
if extension in matrix:
req_version = matrix[extension]
req_version = req_version.replace("<", "\<")
req_version = req_version.replace(">", "\>")
packages.append("{}{}".format(extension, req_version))
else:
packages.append(extension)
# Temp fix for https://github.com/modoboa/modoboa-installer/issues/197
python.install_package(
modoboa_package, self.venv_path, binary=False, sudo_user=self.user)
if self.dbengine == "postgres":
packages.append("psycopg2-binary")
else:
packages.append("mysqlclient")
if sys.version_info.major == 2 and sys.version_info.micro < 9: if sys.version_info.major == 2 and sys.version_info.micro < 9:
# Add extra packages to fix the SNI issue # Add extra packages to fix the SNI issue
packages += ["pyOpenSSL"] packages += ["pyOpenSSL"]
if "modoboa-radicale" in self.extensions:
# Temp. fix
packages += [
"https://github.com/modoboa/caldav/tarball/master#egg=caldav"]
python.install_packages(packages, self.venv_path, sudo_user=self.user) python.install_packages(packages, self.venv_path, sudo_user=self.user)
if self.devmode: if self.devmode:
# FIXME: use dev-requirements instead # FIXME: use dev-requirements instead
@@ -91,9 +129,11 @@ class Modoboa(base.Installer):
"--timezone", self.config.get("modoboa", "timezone"), "--timezone", self.config.get("modoboa", "timezone"),
"--domain", self.config.get("general", "hostname"), "--domain", self.config.get("general", "hostname"),
"--extensions", " ".join(self.extensions), "--extensions", " ".join(self.extensions),
"--dburl", "'default:{0}://{1}:{2}@{3}/{1}'".format( "--dont-install-extensions",
self.config.get("database", "engine"), self.dbname, "--dburl", "'default:{}://{}:{}@{}/{}'".format(
self.dbpasswd, self.dbhost) self.config.get("database", "engine"),
self.dbuser, self.dbpasswd, self.dbhost, self.dbname
)
] ]
if self.devmode: if self.devmode:
args = ["--devel"] + args args = ["--devel"] + args
@@ -140,9 +180,14 @@ class Modoboa(base.Installer):
extensions = self.config.get("modoboa", "extensions") extensions = self.config.get("modoboa", "extensions")
extensions = extensions.split() extensions = extensions.split()
context.update({ context.update({
"sudo_user": (
"uwsgi" if package.backend.FORMAT == "rpm" else context["user"]
),
"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 "#" "radicale_enabled": (
"" if "modoboa-radicale" in extensions else "#"),
"opendkim_user": self.config.get("opendkim", "user"),
}) })
return context return context
@@ -168,11 +213,20 @@ class Modoboa(base.Installer):
}, },
"modoboa_pdfcredentials": { "modoboa_pdfcredentials": {
"storage_dir": pdf_storage_dir "storage_dir": pdf_storage_dir
},
"modoboa_radicale": {
"server_location": "https://{}/radicale/".format(
self.config.get("general", "hostname")),
"rights_file_path": "{}/rights".format(
self.config.get("radicale", "config_dir"))
} }
} }
for path in ["/var/log/maillog", "/var/log/mail.log"]: for path in ["/var/log/maillog", "/var/log/mail.log"]:
if os.path.exists(path): if os.path.exists(path):
settings["modoboa_stats"]["logfile"] = path settings["modoboa_stats"]["logfile"] = path
if self.config.getboolean("opendkim", "enabled"):
settings["admin"]["dkim_keys_storage_dir"] = (
self.config.get("opendkim", "keys_storage_dir"))
settings = json.dumps(settings) settings = json.dumps(settings)
query = ( query = (
"UPDATE core_localconfig SET _parameters='{}'" "UPDATE core_localconfig SET _parameters='{}'"

View File

@@ -29,12 +29,12 @@ class Nginx(base.Installer):
}) })
return context return context
def _setup_config(self, app, hostname=None): def _setup_config(self, app, hostname=None, extra_config=None):
"""Custom app configuration.""" """Custom app configuration."""
if hostname is None: if hostname is None:
hostname = self.config.get("general", "hostname") hostname = self.config.get("general", "hostname")
context = self.get_template_context(app) context = self.get_template_context(app)
context.update({"hostname": hostname}) context.update({"hostname": hostname, "extra_config": extra_config})
src = self.get_file_path("{}.conf.tpl".format(app)) src = self.get_file_path("{}.conf.tpl".format(app))
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
dst = os.path.join( dst = os.path.join(
@@ -57,11 +57,33 @@ class Nginx(base.Installer):
def post_run(self): def post_run(self):
"""Additionnal tasks.""" """Additionnal tasks."""
self._setup_config("modoboa") extra_modoboa_config = ""
if self.config.getboolean("automx", "enabled"): if self.config.getboolean("automx", "enabled"):
hostname = "autoconfig.{}".format( hostname = "autoconfig.{}".format(
self.config.get("general", "domain")) self.config.get("general", "domain"))
self._setup_config("automx", hostname) self._setup_config("automx", hostname)
extra_modoboa_config = """
location /autodiscover/autodiscover.xml {
include uwsgi_params;
uwsgi_pass automx;
}
location /mobileconfig {
include uwsgi_params;
uwsgi_pass automx;
}
"""
if self.config.get("radicale", "enabled"):
extra_modoboa_config += """
location /radicale/ {
proxy_pass http://localhost:5232/; # The / is important!
proxy_set_header X-Script-Name /radicale;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_header Authorization;
}
"""
self._setup_config(
"modoboa", extra_config=extra_modoboa_config)
if not os.path.exists("{}/dhparam.pem".format(self.config_dir)): if not os.path.exists("{}/dhparam.pem".format(self.config_dir)):
cmd = "openssl dhparam -dsaparam -out dhparam.pem 4096" cmd = "openssl dhparam -dsaparam -out dhparam.pem 4096"
utils.exec_cmd(cmd, cwd=self.config_dir) utils.exec_cmd(cmd, cwd=self.config_dir)

View File

@@ -0,0 +1,93 @@
"""OpenDKIM related tools."""
import os
import pwd
import stat
from .. import database
from .. import package
from .. import utils
from . import base
class Opendkim(base.Installer):
"""OpenDKIM installer."""
appname = "opendkim"
packages = {
"deb": ["opendkim"],
"rpm": ["opendkim"]
}
config_files = ["opendkim.conf", "opendkim.hosts"]
def get_packages(self):
"""Additional packages."""
packages = super(Opendkim, self).get_packages()
if package.backend.FORMAT == "deb":
packages += ["libopendbx1-{}".format(self.db_driver)]
else:
dbengine = "postgresql" if self.dbengine == "postgres" else "mysql"
packages += ["opendbx-{}".format(dbengine)]
return packages
def install_config_files(self):
"""Make sure config directory exists."""
user = self.config.get("opendkim", "user")
pw = pwd.getpwnam(user)
targets = [
[self.app_config["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(Opendkim, self).install_config_files()
def get_template_context(self):
"""Additional variables."""
context = super(Opendkim, self).get_template_context()
context.update({
"db_driver": self.db_driver,
"db_name": self.config.get("modoboa", "dbname"),
"db_user": self.app_config["dbuser"],
"db_password": self.app_config["dbpassword"],
"port": self.app_config["port"],
"user": self.app_config["user"]
})
return context
def setup_database(self):
"""Setup database."""
self.backend = database.get_backend(self.config)
self.backend.create_user(
self.app_config["dbuser"], self.app_config["dbpassword"]
)
dbname = self.config.get("modoboa", "dbname")
dbuser = self.config.get("modoboa", "dbuser")
dbpassword = self.config.get("modoboa", "dbpassword")
self.backend.load_sql_file(
dbname, dbuser, dbpassword,
self.get_file_path("dkim_view_{}.sql".format(self.dbengine))
)
self.backend.grant_right_on_table(
dbname, "dkim", self.app_config["dbuser"], "SELECT")
def post_run(self):
"""Addtional tasks."""
if package.backend.FORMAT != "deb":
return
pattern = (
"s/^SOCKET=local:\$RUNDIR\/opendkim\.sock/"
"#SOCKET=local:\$RUNDIR\/opendkim\.sock/"
)
utils.exec_cmd("perl -pi -e '{}' /etc/default/opendkim".format(pattern))
pattern = (
"s/^#SOCKET=inet:12345\@localhost$/"
"SOCKET=inet:12345\@localhost/"
)
utils.exec_cmd("perl -pi -e '{}' /etc/default/opendkim".format(pattern))

View File

@@ -10,6 +10,7 @@ from .. import package
from .. import utils from .. import utils
from . import base from . import base
from . import install
class Postfix(base.Installer): class Postfix(base.Installer):
@@ -59,6 +60,8 @@ class Postfix(base.Installer):
"modoboa", "venv_path"), "modoboa", "venv_path"),
"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")
}) })
return context return context
@@ -92,3 +95,6 @@ class Postfix(base.Installer):
aliases_file = "/etc/aliases" aliases_file = "/etc/aliases"
if os.path.exists(aliases_file): if os.path.exists(aliases_file):
utils.exec_cmd("postalias {}".format(aliases_file)) utils.exec_cmd("postalias {}".format(aliases_file))
# Postwhite
install("postwhite", self.config)

View File

@@ -0,0 +1,51 @@
"""postwhite related functions."""
import os
import shutil
from .. import utils
from . import base
POSTWHITE_REPOSITORY = "https://github.com/stevejenkins/postwhite"
SPF_TOOLS_REPOSITORY = "https://github.com/jsarenik/spf-tools"
class Postwhite(base.Installer):
"""Postwhite installer."""
appname = "postwhite"
config_files = [
"crontab=/etc/cron.d/postwhite",
]
no_daemon = True
packages = {
"rpm": ["bind-utils"]
}
def install_from_archive(self, repository, target_dir):
"""Install from an archive."""
url = "{}/archive/master.zip".format(repository)
target = os.path.join(target_dir, os.path.basename(url))
if os.path.exists(target):
os.unlink(target)
utils.exec_cmd("wget {}".format(url), cwd=target_dir)
app_name = os.path.basename(repository)
archive_dir = os.path.join(target_dir, app_name)
if os.path.exists(archive_dir):
shutil.rmtree(archive_dir)
utils.exec_cmd("unzip master.zip", cwd=target_dir)
utils.exec_cmd(
"mv {name}-master {name}".format(name=app_name), cwd=target_dir)
os.unlink(target)
return archive_dir
def post_run(self):
"""Additionnal tasks."""
install_dir = "/usr/local/bin"
self.install_from_archive(SPF_TOOLS_REPOSITORY, install_dir)
postw_dir = self.install_from_archive(
POSTWHITE_REPOSITORY, install_dir)
utils.copy_file(os.path.join(postw_dir, "postwhite.conf"), "/etc")
postw_bin = os.path.join(postw_dir, "postwhite")
utils.exec_cmd("{} /etc/postwhite.conf".format(postw_bin))

View File

@@ -0,0 +1,79 @@
"""Radicale related tasks."""
import os
import stat
from .. import package
from .. import python
from .. import utils
from . import base
class Radicale(base.Installer):
"""Radicale installation."""
appname = "radicale"
config_files = ["config"]
no_daemon = True
packages = {
"deb": ["supervisor"],
"rpm": ["supervisor"]
}
with_user = True
def __init__(self, config):
"""Get configuration."""
super(Radicale, self).__init__(config)
self.venv_path = config.get("radicale", "venv_path")
def _setup_venv(self):
"""Prepare a dedicated virtualenv."""
python.setup_virtualenv(
self.venv_path, sudo_user=self.user, python_version=3)
packages = ["Radicale", "radicale-dovecot-auth", "pytz"]
python.install_packages(packages, self.venv_path, sudo_user=self.user)
python.install_package_from_repository(
"radicale-storage-by-index",
"https://github.com/tonioo/RadicaleStorageByIndex",
venv=self.venv_path, sudo_user=self.user)
def get_template_context(self):
"""Additional variables."""
context = super(Radicale, self).get_template_context()
radicale_auth_socket_path = self.config.get(
"dovecot", "radicale_auth_socket_path")
context.update({
"auth_socket_path": radicale_auth_socket_path
})
return context
def get_config_files(self):
"""Return appropriate path."""
config_files = super(Radicale, self).get_config_files()
if package.backend.FORMAT == "deb":
path = "supervisor=/etc/supervisor/conf.d/radicale.conf"
else:
path = "supervisor=/etc/supervisord.d/radicale.ini"
config_files.append(path)
return config_files
def install_config_files(self):
"""Make sure config directory exists."""
if not os.path.exists(self.config_dir):
utils.mkdir(
self.config_dir,
stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP |
stat.S_IROTH | stat.S_IXOTH,
0, 0
)
super(Radicale, self).install_config_files()
def post_run(self):
"""Additional tasks."""
self._setup_venv()
daemon_name = (
"supervisor" if package.backend.FORMAT == "deb" else "supervisord"
)
utils.exec_cmd("service {} stop".format(daemon_name))
utils.exec_cmd("service {} start".format(daemon_name))

View File

@@ -62,3 +62,6 @@ class Spamassassin(base.Installer):
sudo_user=amavis_user, login=False sudo_user=amavis_user, login=False
) )
install("razor", self.config) install("razor", self.config)
if utils.dist_name() in ["debian", "ubuntu"]:
utils.exec_cmd(
"perl -pi -e 's/^CRON=0/CRON=1/' /etc/cron.daily/spamassassin")

View File

@@ -84,6 +84,9 @@ class LetsEncryptCertificate(CertificateBackend):
"--post-hook 'service nginx start && " "--post-hook 'service nginx start && "
"service postfix reload && " "service postfix reload && "
"service dovecot reload'") "service dovecot reload'")
cfg_file = "/etc/letsencrypt/renewal/{}.conf".format(hostname)
pattern = "s/authenticator = standalone/authenticator = nginx/"
utils.exec_cmd("perl -pi -e '{}' {}".format(pattern, cfg_file))
def get_backend(config): def get_backend(config):

View File

@@ -10,6 +10,12 @@ import shutil
import string import string
import subprocess import subprocess
import sys import sys
try:
import configparser
except ImportError:
import ConfigParser as configparser
from . import config_dict_template
ENV = {} ENV = {}
@@ -139,23 +145,14 @@ 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):
"""Create a new installer config file if needed.""" """Create a new installer config file if needed."""
if os.path.exists(dest): if os.path.exists(dest):
return return
printcolor( printcolor(
"Configuration file {} not found, creating new one." "Configuration file {} not found, creating new one."
.format(dest), YELLOW) .format(dest), YELLOW)
with open("installer.cfg.template") as fp: gen_config(dest, interactive)
buf = fp.read()
context = {
"mysql_password": make_password(),
"modoboa_password": make_password(),
"amavis_password": make_password(),
"sa_password": make_password()
}
with open(dest, "w") as fp:
fp.write(string.Template(buf).substitute(context))
def has_colours(stream): def has_colours(stream):
@@ -171,6 +168,8 @@ def has_colours(stream):
except: except:
# guess false in case of error # guess false in case of error
return False return False
has_colours = has_colours(sys.stdout) has_colours = has_colours(sys.stdout)
@@ -179,3 +178,109 @@ def printcolor(message, color):
if has_colours: if has_colours:
message = "\x1b[1;{}m{}\x1b[0m".format(30 + color, message) message = "\x1b[1;{}m{}\x1b[0m".format(30 + color, message)
print(message) print(message)
def convert_version_to_int(version):
"""Convert a version string to an integer."""
number_bits = (8, 8, 16)
numbers = [int(number_string) for number_string in version.split(".")]
if len(numbers) > len(number_bits):
raise NotImplementedError(
"Versions with more than {0} decimal places are not supported"
.format(len(number_bits) - 1)
)
# add 0s for missing numbers
numbers.extend([0] * (len(number_bits) - len(numbers)))
# convert to single int and return
number = 0
total_bits = 0
for num, bits in reversed(list(zip(numbers, number_bits))):
max_num = (bits + 1) - 1
if num >= 1 << max_num:
raise ValueError(
"Number {0} cannot be stored with only {1} bits. Max is {2}"
.format(num, bits, max_num)
)
number += num << total_bits
total_bits += bits
return number
def random_key(l=16):
"""Generate a random key.
:param integer l: the key's length
:return: a string
"""
punctuation = """!#$%&()*+,-./:;<=>?@[]^_`{|}~"""
population = string.digits + string.ascii_letters + punctuation
while True:
key = "".join(random.sample(population * l, l))
if len(key) == l:
return key
def validate(value, config_entry):
if value is None:
return False
if "values" not in config_entry and "validators" not in config_entry:
return True
if "values" in config_entry:
try:
value = int(value)
except ValueError:
return False
return value >= 0 and value < len(config_entry["values"])
if "validators" in config_entry:
for validator in config_entry["validators"]:
valide, message = validator(value)
if not valide:
printcolor(message, MAGENTA)
return False
return True
def get_entry_value(entry, interactive):
if callable(entry["default"]):
default_value = entry["default"]()
else:
default_value = entry["default"]
user_value = None
if entry.get("customizable") and interactive:
while (user_value != '' and not validate(user_value, entry)):
question = entry.get("question")
if entry.get("values"):
question += " from the list"
values = entry.get("values")
for index, value in enumerate(values):
question += "\n{} {}".format(index, value)
print(question)
print("default is <{}>".format(default_value))
user_value = user_input("-> ")
if entry.get("values") and user_value != "":
user_value = values[int(user_value)]
return user_value if user_value else default_value
def gen_config(dest, interactive=False):
"""Create config file from dict template"""
tpl_dict = config_dict_template.ConfigDictTemplate
config = configparser.ConfigParser()
# only ask about options we need, else still generate default
for section in tpl_dict:
if "if" in section:
config_key, value = section.get("if").split("=")
section_name, option = config_key.split(".")
interactive_section = (
config.get(section_name, option) == value and interactive)
else:
interactive_section = interactive
config.add_section(section["name"])
for config_entry in section["values"]:
value = get_entry_value(config_entry, interactive_section)
config.set(section["name"], config_entry["option"], value)
with open(dest, "w") as configfile:
config.write(configfile)

39
run.py
View File

@@ -7,33 +7,44 @@ try:
import configparser import configparser
except ImportError: except ImportError:
import ConfigParser as configparser import ConfigParser as configparser
import sys
from modoboa_installer import scripts from modoboa_installer import compatibility_matrix
from modoboa_installer import utils
from modoboa_installer import package from modoboa_installer import package
from modoboa_installer import scripts
from modoboa_installer import ssl from modoboa_installer import ssl
from modoboa_installer import utils
def main(): def main(input_args):
"""Install process.""" """Install process."""
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
versions = (
["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,
help="Force installation") help="Force installation")
parser.add_argument("--configfile", default="installer.cfg", parser.add_argument("--configfile", default="installer.cfg",
help="Configuration file to use") help="Configuration file to use")
parser.add_argument(
"--version", default="latest", choices=versions,
help="Modoboa version to install")
parser.add_argument( parser.add_argument(
"--stop-after-configfile-check", action="store_true", default=False, "--stop-after-configfile-check", action="store_true", default=False,
help="Check configuration, generate it if needed and exit") help="Check configuration, generate it if needed and exit")
parser.add_argument(
"--interactive", action="store_true", default=False,
help="Generate configuration file with user interaction")
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")
args = parser.parse_args() args = parser.parse_args(input_args)
if args.debug: if args.debug:
utils.ENV["debug"] = True utils.ENV["debug"] = True
utils.printcolor("Welcome to Modoboa installer!", utils.GREEN) utils.printcolor("Welcome to Modoboa installer!\n", utils.GREEN)
utils.check_config_file(args.configfile) utils.check_config_file(args.configfile, args.interactive)
if args.stop_after_configfile_check: if args.stop_after_configfile_check:
return return
config = configparser.SafeConfigParser() config = configparser.SafeConfigParser()
@@ -42,6 +53,17 @@ def main():
if not config.has_section("general"): if not config.has_section("general"):
config.add_section("general") config.add_section("general")
config.set("general", "domain", args.domain) config.set("general", "domain", args.domain)
config.set("dovecot", "domain", args.domain)
config.set("modoboa", "version", args.version)
utils.printcolor(
"Warning:\n"
"Before you start the installation, please make sure the following "
"DNS records exist for domain '{}':\n"
" mail IN A <IP ADDRESS OF YOUR SERVER>\n"
" IN MX {}.\n".format(
args.domain, config.get("general", "hostname")),
utils.CYAN
)
utils.printcolor( utils.printcolor(
"Your mail server will be installed with the following components:", "Your mail server will be installed with the following components:",
utils.BLUE) utils.BLUE)
@@ -71,8 +93,10 @@ def main():
scripts.install("amavis", config) scripts.install("amavis", config)
scripts.install("modoboa", config) scripts.install("modoboa", config)
scripts.install("automx", config) scripts.install("automx", config)
scripts.install("radicale", config)
scripts.install("uwsgi", config) scripts.install("uwsgi", config)
scripts.install("nginx", config) scripts.install("nginx", config)
scripts.install("opendkim", config)
scripts.install("postfix", config) scripts.install("postfix", config)
scripts.install("dovecot", config) scripts.install("dovecot", config)
utils.printcolor( utils.printcolor(
@@ -80,5 +104,6 @@ def main():
.format(config.get("general", "hostname")), .format(config.get("general", "hostname")),
utils.GREEN) utils.GREEN)
if __name__ == "__main__": if __name__ == "__main__":
main() main(sys.argv[1:])

3
test-requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
codecov
mock
six

96
tests.py Normal file
View File

@@ -0,0 +1,96 @@
"""Installer unit tests."""
import os
import shutil
import sys
import tempfile
import unittest
from six import StringIO
from six.moves import configparser
try:
from unittest.mock import patch
except ImportError:
from mock import patch
import run
class ConfigFileTestCase(unittest.TestCase):
"""Test configuration file generation."""
def setUp(self):
"""Create temp dir."""
self.workdir = tempfile.mkdtemp()
self.cfgfile = os.path.join(self.workdir, "installer.cfg")
def tearDown(self):
"""Delete temp dir."""
shutil.rmtree(self.workdir)
def test_configfile_generation(self):
"""Check simple case."""
run.main([
"--stop-after-configfile-check",
"--configfile", self.cfgfile,
"example.test"])
self.assertTrue(os.path.exists(self.cfgfile))
@patch("modoboa_installer.utils.user_input")
def test_interactive_mode(self, mock_user_input):
"""Check interactive mode."""
mock_user_input.side_effect = [
"0", "0", "", "", "", "", ""
]
with open(os.devnull, "w") as fp:
sys.stdout = fp
run.main([
"--stop-after-configfile-check",
"--configfile", self.cfgfile,
"--interactive",
"example.test"])
self.assertTrue(os.path.exists(self.cfgfile))
config = configparser.ConfigParser()
config.read(self.cfgfile)
self.assertEqual(config.get("certificate", "type"), "self-signed")
self.assertEqual(config.get("database", "engine"), "postgres")
@patch("modoboa_installer.utils.user_input")
def test_interactive_mode_letsencrypt(self, mock_user_input):
"""Check interactive mode."""
mock_user_input.side_effect = [
"1", "admin@example.test", "0", "", "", "", "", ""
]
with open(os.devnull, "w") as fp:
sys.stdout = fp
run.main([
"--stop-after-configfile-check",
"--configfile", self.cfgfile,
"--interactive",
"example.test"])
self.assertTrue(os.path.exists(self.cfgfile))
config = configparser.ConfigParser()
config.read(self.cfgfile)
self.assertEqual(config.get("certificate", "type"), "letsencrypt")
self.assertEqual(
config.get("letsencrypt", "email"), "admin@example.test")
@patch("modoboa_installer.utils.user_input")
def test_configfile_loading(self, mock_user_input):
"""Check interactive mode."""
mock_user_input.side_effect = ["no"]
out = StringIO()
sys.stdout = out
run.main([
"--configfile", self.cfgfile,
"example.test"])
self.assertTrue(os.path.exists(self.cfgfile))
self.assertIn(
"modoboa automx amavis clamav dovecot nginx razor postfix"
" postwhite spamassassin uwsgi",
out.getvalue()
)
if __name__ == "__main__":
unittest.main()