42 Commits

Author SHA1 Message Date
github-actions[bot]
00d79f518d [GitHub Action] Updated version file 2025-11-21 15:12:39 +00:00
Antoine Nguyen
03b124501e Updated nginx config to redirect autodiscover requests to modoboa 2025-11-21 16:10:58 +01:00
github-actions[bot]
5f357aef42 [GitHub Action] Updated version file 2025-11-17 08:13:43 +00:00
Antoine Nguyen
e1aa0ab723 Removed legacy option for radicale socket 2025-11-17 09:12:32 +01:00
github-actions[bot]
88b2384fa8 [GitHub Action] Updated version file 2025-11-07 15:22:17 +00:00
Antoine Nguyen
bb02255c0f Merge pull request #613 from modoboa/fix/oauth2-client-secrets
Make sure to reuse same client secrets between runs.
2025-11-07 16:21:09 +01:00
Antoine Nguyen
7a38a535f8 Make sure to reuse same client secrets between runs. 2025-11-07 16:09:51 +01:00
github-actions[bot]
2121cfe267 [GitHub Action] Updated version file 2025-11-02 10:01:31 +00:00
Antoine Nguyen
36c8352223 Added missing config param
fix #605
2025-11-02 10:59:54 +01:00
github-actions[bot]
01ec9b406f [GitHub Action] Updated version file 2025-11-02 09:33:05 +00:00
Antoine Nguyen
f2c7423296 Merge pull request #610 from modoboa/mailbox-auto-creation
Added mailbox file and activate the auto subscribe
2025-11-02 10:32:00 +01:00
Spitap
9276953f87 Created 15-mailboxes.conf.tpl 2025-11-01 10:42:20 +01:00
Spitap
11f6f646d1 Fixed typos 2025-10-31 22:51:01 +01:00
Spitap
46d7c6acca Added mailbox file and activate the auto subscribe 2025-10-31 16:36:08 +01:00
github-actions[bot]
0e64b92199 [GitHub Action] Updated version file 2025-10-13 07:27:25 +00:00
Antoine Nguyen
1d701353d9 Merge pull request #604 from CooperDActor-bytes/patch-1
Fix Grammar
2025-10-13 09:26:14 +02:00
Cooper D'Andilly
3acec87ab5 Fix Grammar 2025-10-12 17:05:05 +11:00
github-actions[bot]
dd9c7457c8 [GitHub Action] Updated version file 2025-09-26 15:02:26 +00:00
Antoine Nguyen
73feb967fe cover mysql use case for dict definition in dovecot 2.4
see #602
2025-09-26 17:00:29 +02:00
github-actions[bot]
ad3f9f8cef [GitHub Action] Updated version file 2025-09-25 11:59:29 +00:00
Antoine Nguyen
765b56d48a Do not install radicale-storage-by-index radicale plugin anymore 2025-09-25 13:58:05 +02:00
github-actions[bot]
a9a8f8888c [GitHub Action] Updated version file 2025-09-24 09:24:28 +00:00
Antoine Nguyen
355e8d9b98 Complete MariaDB fix on debian 12
fix #598
2025-09-24 11:22:51 +02:00
github-actions[bot]
212fa9b5c1 [GitHub Action] Updated version file 2025-09-24 09:06:37 +00:00
Antoine Nguyen
65961209c8 Fixed issue with MariaDB and Debian 13
see #598
2025-09-24 11:05:09 +02:00
github-actions[bot]
5a6f5e7d71 [GitHub Action] Updated version file 2025-09-23 16:38:15 +00:00
Antoine Nguyen
eed7603ba1 Complete fix for #601 2025-09-23 18:36:41 +02:00
github-actions[bot]
731df935e5 [GitHub Action] Updated version file 2025-09-23 14:28:39 +00:00
Antoine Nguyen
6f1e717fc4 Fixed issue with autoconfig setup
see #601
2025-09-23 16:27:05 +02:00
github-actions[bot]
c7f24218aa [GitHub Action] Updated version file 2025-09-23 09:28:12 +00:00
Antoine Nguyen
3d80bc3131 Merge pull request #597 from modoboa/automx-replacement
Replaced automx by Modoboa autoconfig service
2025-09-23 11:27:06 +02:00
github-actions[bot]
67971ee981 [GitHub Action] Updated version file 2025-09-23 08:14:31 +00:00
Antoine Nguyen
34bf98452d Merge pull request #600 from FranMercedesG/master
Fix amavis installation on Debian 13 (LZ4)
2025-09-23 10:14:02 +02:00
github-actions[bot]
6e9fc2e7c2 [GitHub Action] Updated version file 2025-09-23 08:13:44 +00:00
Antoine Nguyen
90d4aac001 Merge pull request #599 from bergerc/master
Add missing dependancy on unzip for postwhite.
2025-09-23 10:12:37 +02:00
FranMercedesG
22cbc2e278 fix amavis installation on debian 13 2025-09-23 01:02:15 -04:00
Chris
2dbec9d2a5 Add missing dependancy on unzip for postwhite. 2025-09-22 22:13:09 +02:00
github-actions[bot]
9d91a25293 [GitHub Action] Updated version file 2025-09-20 09:48:04 +00:00
Antoine Nguyen
246419407a Fixed wrong path 2025-09-20 11:46:45 +02:00
Antoine Nguyen
fc81c04220 Replaced automx by Modoboa autoconfig service 2025-09-16 18:12:11 +02:00
github-actions[bot]
ff485f0d25 [GitHub Action] Updated version file 2025-09-16 13:43:42 +00:00
Antoine Nguyen
56be1be372 Merge pull request #596 from modoboa/dovecot-24-support
Dovecot 2.4 support
2025-09-16 15:42:31 +02:00
25 changed files with 300 additions and 321 deletions

View File

@@ -3,11 +3,11 @@
|workflow| |codecov| |workflow| |codecov|
An installer which deploy a complete mail server based on Modoboa. An installer which deploys a complete mail server based on Modoboa.
.. warning:: .. warning::
This tool is still in beta stage, it has been tested on: This tool is still in beta, it has been tested on:
* Debian 12 and upper * Debian 12 and upper
* Ubuntu Focal Fossa (20.04) and upper * Ubuntu Focal Fossa (20.04) and upper
@@ -44,7 +44,6 @@ The following components are installed by the installer:
* Postfix * Postfix
* Dovecot * Dovecot
* Amavis (with SpamAssassin and ClamAV) or Rspamd * Amavis (with SpamAssassin and ClamAV) or Rspamd
* automx (autoconfiguration service)
* OpenDKIM * OpenDKIM
* Radicale (CalDAV and CardDAV server) * Radicale (CalDAV and CardDAV server)

View File

@@ -1,5 +1,6 @@
import random import random
import string import string
import uuid
from .constants import DEFAULT_BACKUP_DIRECTORY from .constants import DEFAULT_BACKUP_DIRECTORY
@@ -11,6 +12,10 @@ def make_password(length=16):
string.ascii_letters + string.digits) for _ in range(length)) string.ascii_letters + string.digits) for _ in range(length))
def make_client_secret():
return str(uuid.uuid4())
# Validators should return a tuple bool, error message # Validators should return a tuple bool, error message
def is_email(user_input): def is_email(user_input):
"""Return True in input is a valid email""" """Return True in input is a valid email"""
@@ -222,35 +227,6 @@ ConfigDictTemplate = [
}, },
] ]
}, },
{
"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": "rspamd", "name": "rspamd",
"if": ["antispam.enabled=true", "antispam.type=rspamd"], "if": ["antispam.enabled=true", "antispam.type=rspamd"],
@@ -380,6 +356,10 @@ ConfigDictTemplate = [
"option": "move_spam_to_junk", "option": "move_spam_to_junk",
"default": "true", "default": "true",
}, },
{
"option": "oauth2_client_secret",
"default": make_client_secret
},
] ]
}, },
{ {
@@ -509,7 +489,11 @@ ConfigDictTemplate = [
{ {
"option": "venv_path", "option": "venv_path",
"default": "%(home_dir)s/env", "default": "%(home_dir)s/env",
} },
{
"option": "oauth2_client_secret",
"default": make_client_secret
},
] ]
}, },
{ {

View File

@@ -3,19 +3,19 @@
import os import os
import pwd import pwd
import stat import stat
from typing import Optional
from . import package from . import package
from . import system from . import system
from . import utils from . import utils
class Database(object): class Database:
"""Common database backend.""" """Common database backend."""
default_port = None default_port: Optional[int] = None
packages = None packages: Optional[dict[str, list[str]]] = None
service = None service: Optional[str] = None
def __init__(self, config): def __init__(self, config):
"""Install if necessary.""" """Install if necessary."""
@@ -36,7 +36,6 @@ class Database(object):
class PostgreSQL(Database): class PostgreSQL(Database):
"""Postgres.""" """Postgres."""
default_port = 5432 default_port = 5432
@@ -157,7 +156,6 @@ class PostgreSQL(Database):
class MySQL(Database): class MySQL(Database):
"""MySQL backend.""" """MySQL backend."""
default_port = 3306 default_port = 3306
@@ -178,7 +176,7 @@ class MySQL(Database):
if name.startswith("debian"): if name.startswith("debian"):
if version.startswith("8"): if version.startswith("8"):
self.packages["deb"].append("libmysqlclient-dev") self.packages["deb"].append("libmysqlclient-dev")
elif version.startswith("11") or version.startswith("12"): elif int(version[:2]) >= 11:
self.packages["deb"].append("libmariadb-dev") self.packages["deb"].append("libmariadb-dev")
else: else:
self.packages["deb"].append("libmariadbclient-dev") self.packages["deb"].append("libmariadbclient-dev")
@@ -188,7 +186,7 @@ class MySQL(Database):
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")
super(MySQL, self).install_package() super().install_package()
queries = [] queries = []
if name.startswith("debian"): if name.startswith("debian"):
if version.startswith("8"): if version.startswith("8"):
@@ -200,7 +198,7 @@ class MySQL(Database):
self.dbpassword) self.dbpassword)
return return
if ( if (
(name.startswith("debian") and (version.startswith("11") or version.startswith("12"))) or (name.startswith("debian") and int(version[:2]) >= 11) or
(name.startswith("ubuntu") and int(version[:2]) >= 22) (name.startswith("ubuntu") and int(version[:2]) >= 22)
): ):
queries = [ queries = [

View File

@@ -52,7 +52,17 @@ class Amavis(base.Installer):
packages = super(Amavis, self).get_packages() packages = super(Amavis, self).get_packages()
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
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)] packages += ["libdbd-{}-perl".format(db_driver)]
name, version = utils.dist_info()
try:
major_version = int(version.split(".")[0])
except ValueError:
major_version = 0
if major_version >= 13:
packages = [p if p != "liblz4-tool" else "lz4" for p in packages]
return packages
if self.db_driver == "pgsql": if self.db_driver == "pgsql":
db_driver = "Pg" db_driver = "Pg"
elif self.db_driver == "mysql": elif self.db_driver == "mysql":

View File

@@ -1,101 +0,0 @@
"""Automx related tasks."""
import os
import pwd
import shutil
import stat
from .. import python
from .. import system
from .. import utils
from . import base
class Automx(base.Installer):
"""Automx installation."""
appname = "automx"
config_files = ["automx.conf"]
no_daemon = True
packages = {
"deb": ["memcached", "unzip"],
"rpm": ["memcached", "unzip"]
}
with_user = True
def __init__(self, *args, **kwargs):
"""Get configuration."""
super(Automx, self).__init__(*args, **kwargs)
self.venv_path = self.config.get("automx", "venv_path")
self.instance_path = self.config.get("automx", "instance_path")
def get_template_context(self):
"""Additional variables."""
context = super(Automx, self).get_template_context()
sql_dsn = "{}://{}:{}@{}:{}/{}".format(
"postgresql" if self.dbengine == "postgres" else self.dbengine,
self.config.get("modoboa", "dbuser"),
self.config.get("modoboa", "dbpassword"),
self.dbhost,
self.dbport,
self.config.get("modoboa", "dbname"))
if self.db_driver == "pgsql":
sql_query = (
"SELECT first_name || ' ' || last_name AS display_name, email"
", SPLIT_PART(email, '@', 2) AS domain "
"FROM core_user WHERE email='%s' AND is_active;")
else:
sql_query = (
"SELECT concat(first_name, ' ', last_name) AS display_name, "
"email, SUBSTRING_INDEX(email, '@', -1) AS domain "
"FROM core_user WHERE email='%s' AND is_active=1;"
)
context.update({"sql_dsn": sql_dsn, "sql_query": sql_query})
return context
def _setup_venv(self):
"""Prepare a python virtualenv."""
python.setup_virtualenv(self.venv_path, sudo_user=self.user)
packages = [
"future", "lxml", "ipaddress", "sqlalchemy < 2.0", "python-memcached",
"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)
target = "{}/master.zip".format(self.home_dir)
if os.path.exists(target):
os.unlink(target)
utils.exec_cmd(
"wget https://github.com/sys4/automx/archive/master.zip",
sudo_user=self.user, cwd=self.home_dir)
self.repo_dir = "{}/automx-master".format(self.home_dir)
if os.path.exists(self.repo_dir):
shutil.rmtree(self.repo_dir)
utils.exec_cmd(
"unzip master.zip", sudo_user=self.user, cwd=self.home_dir)
utils.exec_cmd(
"{} setup.py install".format(
python.get_path("python", self.venv_path)),
cwd=self.repo_dir)
def _deploy_instance(self):
"""Copy files to instance dir."""
if not os.path.exists(self.instance_path):
pw = pwd.getpwnam(self.user)
mode = (
stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP |
stat.S_IROTH | stat.S_IXOTH)
utils.mkdir(self.instance_path, mode, pw[2], pw[3])
path = "{}/src/automx_wsgi.py".format(self.repo_dir)
utils.exec_cmd("cp {} {}".format(path, self.instance_path),
sudo_user=self.user, cwd=self.home_dir)
def post_run(self):
"""Additional tasks."""
self._setup_venv()
self._deploy_instance()
system.enable_and_start_service("memcached")

View File

@@ -2,6 +2,7 @@
import os import os
import sys import sys
from typing import Optional
from .. import database from .. import database
from .. import package from .. import package
@@ -13,15 +14,15 @@ from .. import utils
class Installer: class Installer:
"""Simple installer for one application.""" """Simple installer for one application."""
appname = None appname: str
no_daemon = False no_daemon: bool = False
daemon_name = None daemon_name: Optional[str] = None
packages = {} packages: dict[str, list[str]] = {}
with_user = False with_user: bool = False
with_db = False with_db: bool = False
config_files = [] config_files: list[str] = []
def __init__(self, config, upgrade: bool, archive_path: str): def __init__(self, config, upgrade: bool, archive_path: str) -> None:
"""Get configuration.""" """Get configuration."""
self.config = config self.config = config
self.upgrade = upgrade self.upgrade = upgrade
@@ -44,7 +45,7 @@ class Installer:
self.dbpasswd = self.config.get(self.appname, "dbpassword") self.dbpasswd = self.config.get(self.appname, "dbpassword")
@property @property
def modoboa_2_2_or_greater(self): def modoboa_2_2_or_greater(self) -> bool:
# Check if modoboa version > 2.2 # Check if modoboa version > 2.2
modoboa_version = python.get_package_version( modoboa_version = python.get_package_version(
"modoboa", "modoboa",

View File

@@ -23,10 +23,9 @@ class Dovecot(base.Installer):
"dovecot-imapd", "dovecot-imapd",
"dovecot-lmtpd", "dovecot-lmtpd",
"dovecot-managesieved", "dovecot-managesieved",
"dovecot-sieve" "dovecot-sieve",
], ],
"rpm": [ "rpm": ["dovecot", "dovecot-pigeonhole"],
"dovecot", "dovecot-pigeonhole"]
} }
per_version_config_files = { per_version_config_files = {
"2.3": [ "2.3": [
@@ -44,10 +43,10 @@ class Dovecot(base.Installer):
"conf.d/10-master.conf", "conf.d/10-master.conf",
"conf.d/10-ssl.conf", "conf.d/10-ssl.conf",
"conf.d/10-ssl-keys.try", "conf.d/10-ssl-keys.try",
"conf.d/15-mailboxes.conf",
"conf.d/20-lmtp.conf", "conf.d/20-lmtp.conf",
"conf.d/30-dict-server.conf",
"conf.d/auth-oauth2.conf.ext", "conf.d/auth-oauth2.conf.ext",
] ],
} }
with_user = True with_user = True
@@ -69,11 +68,12 @@ class Dovecot(base.Installer):
files += [ files += [
f"conf.d/auth-sql-{self.dbengine}.conf.ext=conf.d/auth-sql.conf.ext", f"conf.d/auth-sql-{self.dbengine}.conf.ext=conf.d/auth-sql.conf.ext",
f"conf.d/auth-master-{self.dbengine}.conf.ext=conf.d/auth-master.conf.ext", f"conf.d/auth-master-{self.dbengine}.conf.ext=conf.d/auth-master.conf.ext",
f"conf.d/30-dict-server-{self.dbengine}.conf=conf.d/30-dict-server.conf",
] ]
else: else:
files += [ files += [
f"dovecot-sql-{self.dbengine}.conf.ext=dovecot-sql.conf.ext", f"dovecot-sql-{self.dbengine}.conf.ext=dovecot-sql.conf.ext",
f"dovecot-sql-master-{self.dbengine}.conf.ext=dovecot-sql-master.conf.ext" f"dovecot-sql-master-{self.dbengine}.conf.ext=dovecot-sql-master.conf.ext",
] ]
result = [] result = []
for path in files: for path in files:
@@ -107,7 +107,9 @@ class Dovecot(base.Installer):
packages += super().get_packages() packages += super().get_packages()
backports_codename = getattr(self, "backports_codename", None) backports_codename = getattr(self, "backports_codename", None)
if backports_codename: if backports_codename:
packages = [f"{package}/{backports_codename}-backports" for package in packages] packages = [
f"{package}/{backports_codename}-backports" for package in packages
]
return packages return packages
def install_packages(self): def install_packages(self):
@@ -118,7 +120,8 @@ class Dovecot(base.Installer):
package.backend.enable_backports("bookworm") package.backend.enable_backports("bookworm")
self.backports_codename = "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().install_packages()
def get_template_context(self): def get_template_context(self):
@@ -127,11 +130,17 @@ class Dovecot(base.Installer):
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"
if package.backend.get_installed_version(dovecot_package[package.backend.FORMAT]) > "2.3": if (
package.backend.get_installed_version(
dovecot_package[package.backend.FORMAT]
)
> "2.3"
):
ssl_protocol_parameter = "ssl_min_protocol" ssl_protocol_parameter = "ssl_min_protocol"
ssl_protocols = "!SSLv2 !SSLv3" ssl_protocols = "!SSLv2 !SSLv3"
if package.backend.get_installed_version("openssl").startswith("1.1") \ if package.backend.get_installed_version("openssl").startswith(
or package.backend.get_installed_version("openssl").startswith("3"): "1.1"
) or package.backend.get_installed_version("openssl").startswith("3"):
ssl_protocols = "!SSLv3" ssl_protocols = "!SSLv3"
if ssl_protocol_parameter == "ssl_min_protocol": if ssl_protocol_parameter == "ssl_min_protocol":
ssl_protocols = "TLSv1.2" ssl_protocols = "TLSv1.2"
@@ -145,31 +154,41 @@ class Dovecot(base.Installer):
protocols = "" protocols = ""
oauth2_client_id, oauth2_client_secret = utils.create_oauth2_app( oauth2_client_id, oauth2_client_secret = utils.create_oauth2_app(
"Dovecot", "dovecot", self.config) "Dovecot",
"dovecot",
self.config.get("dovecot", "oauth2_client_secret"),
self.config
)
hostname = self.config.get("general", "hostname") hostname = self.config.get("general", "hostname")
oauth2_introspection_url = ( oauth2_introspection_url = (
f"https://{oauth2_client_id}:{oauth2_client_secret}" f"https://{oauth2_client_id}:{oauth2_client_secret}"
f"@{hostname}/api/o/introspect/" f"@{hostname}/api/o/introspect/"
) )
context.update({ context.update(
"db_driver": self.db_driver, {
"mailboxes_owner_uid": pw_mailbox[2], "db_driver": self.db_driver,
"mailboxes_owner_gid": pw_mailbox[3], "mailboxes_owner_uid": pw_mailbox[2],
"mailbox_owner": self.mailboxes_owner, "mailboxes_owner_gid": pw_mailbox[3],
"modoboa_user": self.config.get("modoboa", "user"), "mailbox_owner": self.mailboxes_owner,
"modoboa_dbname": self.config.get("modoboa", "dbname"), "modoboa_user": self.config.get("modoboa", "user"),
"modoboa_dbuser": self.config.get("modoboa", "dbuser"), "modoboa_dbname": self.config.get("modoboa", "dbname"),
"modoboa_dbpassword": self.config.get("modoboa", "dbpassword"), "modoboa_dbuser": self.config.get("modoboa", "dbuser"),
"protocols": protocols, "modoboa_dbpassword": self.config.get("modoboa", "dbpassword"),
"ssl_protocols": ssl_protocols, "protocols": protocols,
"ssl_protocol_parameter": ssl_protocol_parameter, "ssl_protocols": ssl_protocols,
"modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#", "ssl_protocol_parameter": ssl_protocol_parameter,
"not_modoboa_2_2_or_greater": "" if not self.modoboa_2_2_or_greater else "#", "modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#",
"do_move_spam_to_junk": "" if self.app_config["move_spam_to_junk"] else "#", "not_modoboa_2_2_or_greater": (
"oauth2_introspection_url": oauth2_introspection_url, "" if not self.modoboa_2_2_or_greater else "#"
"radicale_user": self.config.get("radicale", "user"), ),
}) "do_move_spam_to_junk": (
"" if self.app_config["move_spam_to_junk"] else "#"
),
"oauth2_introspection_url": oauth2_introspection_url,
"radicale_user": self.config.get("radicale", "user"),
}
)
return context return context
def install_config_files(self): def install_config_files(self):
@@ -177,10 +196,14 @@ class Dovecot(base.Installer):
if self.app_config["move_spam_to_junk"]: if self.app_config["move_spam_to_junk"]:
utils.mkdir_safe( utils.mkdir_safe(
f"{self.config_dir}/conf.d/custom_after_sieve", f"{self.config_dir}/conf.d/custom_after_sieve",
stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IRWXU
stat.S_IROTH | stat.S_IXOTH, | stat.S_IRGRP
0, 0 | stat.S_IXGRP
) | stat.S_IROTH
| stat.S_IXOTH,
0,
0,
)
super().install_config_files() super().install_config_files()
def post_run(self): def post_run(self):
@@ -191,14 +214,18 @@ class Dovecot(base.Installer):
dbpassword = self.config.get("modoboa", "dbpassword") dbpassword = self.config.get("modoboa", "dbpassword")
backend = database.get_backend(self.config) backend = database.get_backend(self.config)
backend.load_sql_file( backend.load_sql_file(
dbname, dbuser, dbpassword, dbname,
self.get_file_path("install_modoboa_postgres_trigger.sql") dbuser,
dbpassword,
self.get_file_path("install_modoboa_postgres_trigger.sql"),
) )
backend.load_sql_file( backend.load_sql_file(
dbname, dbuser, dbpassword, dbname,
self.get_file_path("fix_modoboa_postgres_schema.sql") dbuser,
dbpassword,
self.get_file_path("fix_modoboa_postgres_schema.sql"),
) )
for f in glob.glob(f"{self.version}/{self.get_file_path('conf.d')}/*"): for f in glob.glob(f"{self.get_file_path(f'{self.version}/conf.d')}/*"):
if os.path.isfile(f): 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
@@ -210,9 +237,11 @@ class Dovecot(base.Installer):
# See https://github.com/modoboa/modoboa/issues/2157. # See https://github.com/modoboa/modoboa/issues/2157.
if self.app_config["move_spam_to_junk"]: if self.app_config["move_spam_to_junk"]:
# Compile sieve script # Compile sieve script
sieve_file = f"{self.config_dir}/conf.d/custom_after_sieve/spam-to-junk.sieve" sieve_file = (
f"{self.config_dir}/conf.d/custom_after_sieve/spam-to-junk.sieve"
)
utils.exec_cmd(f"/usr/bin/sievec {sieve_file}") 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):
"""Restart daemon process. """Restart daemon process.
@@ -226,7 +255,8 @@ class Dovecot(base.Installer):
action = "start" if code else "restart" action = "start" if code else "restart"
utils.exec_cmd( utils.exec_cmd(
"service {} {} > /dev/null 2>&1".format(self.appname, action), "service {} {} > /dev/null 2>&1".format(self.appname, action),
capture_output=False) capture_output=False,
)
system.enable_service(self.get_daemon_name()) system.enable_service(self.get_daemon_name())
def backup(self, path): def backup(self, path):
@@ -234,8 +264,10 @@ class Dovecot(base.Installer):
home_dir = self.config.get("dovecot", "home_dir") home_dir = self.config.get("dovecot", "home_dir")
utils.printcolor("Backing up mails", utils.MAGENTA) utils.printcolor("Backing up mails", utils.MAGENTA)
if not os.path.exists(home_dir) or os.path.isfile(home_dir): if not os.path.exists(home_dir) or os.path.isfile(home_dir):
utils.error("Error backing up emails, provided path " utils.error(
f" ({home_dir}) seems not right...") "Error backing up emails, provided path "
f" ({home_dir}) seems not right..."
)
return return
dst = os.path.join(path, "mails/") dst = os.path.join(path, "mails/")
@@ -257,10 +289,13 @@ class Dovecot(base.Installer):
for dirpath, dirnames, filenames in os.walk(home_dir): for dirpath, dirnames, filenames in os.walk(home_dir):
shutil.chown(dirpath, self.mailboxes_owner, self.mailboxes_owner) shutil.chown(dirpath, self.mailboxes_owner, self.mailboxes_owner)
for filename in filenames: for filename in filenames:
shutil.chown(os.path.join(dirpath, filename), shutil.chown(
self.mailboxes_owner, self.mailboxes_owner) os.path.join(dirpath, filename),
self.mailboxes_owner,
self.mailboxes_owner,
)
else: else:
utils.printcolor( utils.printcolor(
"It seems that emails were not backed up, skipping restoration.", "It seems that emails were not backed up, skipping restoration.",
utils.MAGENTA utils.MAGENTA,
) )

View File

@@ -1,39 +0,0 @@
[automx]
provider = %domain
domains = *
#debug=yes
#logfile = /srv/automx/automx.log
# Protect against DoS
memcache = 127.0.0.1:11211
memcache_ttl = 600
client_error_limit = 20
rate_limit_exception_networks = 127.0.0.0/8, ::1/128
[global]
backend = sql
action = settings
account_type = email
host = %sql_dsn
query = %sql_query
result_attrs = display_name, email
display_name = ${display_name}
smtp = yes
smtp_server = %hostname
smtp_port = 587
smtp_encryption = starttls
smtp_auth = plaintext
smtp_auth_identity = ${email}
smtp_refresh_ttl = 6
smtp_default = yes
imap = yes
imap_server = %hostname
imap_port = 143
imap_encryption = starttls
imap_auth = plaintext
imap_auth_identity = ${email}
imap_refresh_ttl = 6

View File

@@ -149,7 +149,6 @@ service auth {
%{radicale_enabled} mode = 0666 %{radicale_enabled} mode = 0666
%{radicale_enabled} user = %{radicale_user} %{radicale_enabled} user = %{radicale_user}
%{radicale_enabled} group = %{radicale_user} %{radicale_enabled} group = %{radicale_user}
%{radicale_enabled} type = auth-legacy
%{radicale_enabled}} %{radicale_enabled}}
# Auth process is run as this user. # Auth process is run as this user.

View File

@@ -0,0 +1,90 @@
##
## Mailbox definitions
##
# Each mailbox is specified in a separate mailbox section. The section name
# specifies the mailbox name. If it has spaces, you can put the name
# "in quotes". These sections can contain the following mailbox settings:
#
# auto:
# Indicates whether the mailbox with this name is automatically created
# implicitly when it is first accessed. The user can also be automatically
# subscribed to the mailbox after creation. The following values are
# defined for this setting:
#
# no - Never created automatically.
# create - Automatically created, but no automatic subscription.
# subscribe - Automatically created and subscribed.
#
# special_use:
# A space-separated list of SPECIAL-USE flags (RFC 6154) to use for the
# mailbox. There are no validity checks, so you could specify anything
# you want in here, but it's not a good idea to use flags other than the
# standard ones specified in the RFC:
#
# \All - This (virtual) mailbox presents all messages in the
# user's message store.
# \Archive - This mailbox is used to archive messages.
# \Drafts - This mailbox is used to hold draft messages.
# \Flagged - This (virtual) mailbox presents all messages in the
# user's message store marked with the IMAP \Flagged flag.
# \Important - This (virtual) mailbox presents all messages in the
# user's message store deemed important to user.
# \Junk - This mailbox is where messages deemed to be junk mail
# are held.
# \Sent - This mailbox is used to hold copies of messages that
# have been sent.
# \Trash - This mailbox is used to hold messages that have been
# deleted.
#
# comment:
# Defines a default comment or note associated with the mailbox. This
# value is accessible through the IMAP METADATA mailbox entries
# "/shared/comment" and "/private/comment". Users with sufficient
# privileges can override the default value for entries with a custom
# value.
# NOTE: Assumes "namespace inbox" has been defined in 10-mail.conf.
namespace inbox {
# These mailboxes are widely used and could perhaps be created automatically:
mailbox Drafts {
auto = subscribe
special_use = \Drafts
}
mailbox Junk {
auto = subscribe
special_use = \Junk
}
mailbox Trash {
auto = subscribe
special_use = \Trash
}
# For \Sent mailboxes there are two widely used names. We'll mark both of
# them as \Sent. User typically deletes one of them if duplicates are created.
mailbox Sent {
auto = subscribe
special_use = \Sent
}
# mailbox "Sent Messages" {
# special_use = \Sent
# }
# If you have a virtual "All messages" mailbox:
#mailbox virtual/All {
# special_use = \All
# comment = All my messages
#}
# If you have a virtual "Flagged" mailbox:
#mailbox virtual/Flagged {
# special_use = \Flagged
# comment = All my flagged messages
#}
# If you have a virtual "Important" mailbox:
#mailbox virtual/Important {
# special_use = \Important
# comment = All my important messages
#}
}

View File

@@ -0,0 +1,45 @@
##
## Dictionary server settings
##
# Dictionary can be used to store key=value lists. This is used by several
# plugins. The dictionary can be accessed either directly or though a
# dictionary server. The following dict block maps dictionary names to URIs
# when the server is used. These can then be referenced using URIs in format
# "proxy::<name>".
dict_server {
mysql %dbhost {
port = %dbport
dbname = %modoboa_dbname
user = %modoboa_dbuser
password = %modoboa_dbpassword
}
dict quota {
driver = sql
sql_driver = %db_driver
hostname = %dbhost
dict_map priv/quota/storage {
sql_table = admin_quota
username_field = username
value_field bytes {
type = uint
}
}
dict_map priv/quota/messages {
sql_table = admin_quota
username_field = username
value_field messages {
type = uint
}
}
}
}
quota_clone {
dict proxy {
name = quota
}
}

View File

@@ -104,21 +104,21 @@ mysql %dbhost {
# #
# Commonly used available substitutions (see https://doc.dovecot.org/latest/core/settings/variables.html # Commonly used available substitutions (see https://doc.dovecot.org/latest/core/settings/variables.html
# for full list): # for full list):
# %{user} = entire user@domain # %%{user} = entire user@domain
# %{user|username} = user part of user@domain # %%{user|username} = user part of user@domain
# %{user|domain} = domain part of user@domain # %%{user|domain} = domain part of user@domain
# #
# Note that these can be used only as input to SQL query. If the query outputs # Note that these can be used only as input to SQL query. If the query outputs
# any of these substitutions, they're not touched. Otherwise it would be # any of these substitutions, they're not touched. Otherwise it would be
# difficult to have eg. usernames containing '%' characters. # difficult to have eg. usernames containing '%%' characters.
# #
# Example: # Example:
# query = SELECT userid AS user, pw AS password \ # query = SELECT userid AS user, pw AS password \
# FROM users WHERE userid = '%u' AND active = 'Y' # FROM users WHERE userid = '%%u' AND active = 'Y'
# #
# query = \ # query = \
# SELECT userid as username, domain, password \ # SELECT userid as username, domain, password \
# FROM users WHERE userid = '%{user|username}' AND domain = '%{user|domain}' # FROM users WHERE userid = '%%{user|username}' AND domain = '%%{user|domain}'
#} #}
passdb sql { passdb sql {

View File

@@ -1,18 +1,15 @@
upstream automx {
server unix:%uwsgi_socket_path fail_timeout=0;
}
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name %hostname; server_name %hostname;
root /srv/automx/instance; root %app_instance_path;
access_log /var/log/nginx/%{hostname}-access.log; access_log /var/log/nginx/%{hostname}-access.log;
error_log /var/log/nginx/%{hostname}-error.log; error_log /var/log/nginx/%{hostname}-error.log;
location /mail/config-v1.1.xml { location ~ ^/(mail/config-v1.1.xml|mobileconfig) {
include uwsgi_params; include uwsgi_params;
uwsgi_pass automx; uwsgi_param UWSGI_SCRIPT instance.wsgi:application;
uwsgi_pass modoboa;
} }
} }

View File

@@ -44,7 +44,7 @@ server {
%{rspamd_enabled} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; %{rspamd_enabled} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
%{rspamd_enabled} } %{rspamd_enabled} }
location ~ ^/(api|accounts) { location ~ ^/(api|accounts|autodiscover) {
include uwsgi_params; include uwsgi_params;
uwsgi_param UWSGI_SCRIPT instance.wsgi:application; uwsgi_param UWSGI_SCRIPT instance.wsgi:application;
uwsgi_pass modoboa; uwsgi_pass modoboa;

View File

@@ -102,8 +102,6 @@ file = %{config_dir}/rights
# Storage backend # Storage backend
# Value: multifilesystem # 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 # Folder for storing local collections, created if not present
filesystem_folder = %{home_dir}/collections filesystem_folder = %{home_dir}/collections

View File

@@ -1,14 +0,0 @@
[uwsgi]
uid = %app_user
gid = %app_user
plugins = %uwsgi_plugin
home = %app_venv_path
chdir = %app_instance_path
module = automx_wsgi
master = true
vhost = true
harakiri = 60
processes = %nb_processes
socket = %uwsgi_socket_path
chmod-socket = 660
vacuum = true

View File

@@ -19,14 +19,15 @@ class Nginx(base.Installer):
"rpm": ["nginx"] "rpm": ["nginx"]
} }
def get_template_context(self, app): def get_template_context(self):
"""Additionnal variables.""" """Additionnal variables."""
context = super().get_template_context() context = super().get_template_context()
context.update({ context.update({
"app_instance_path": ( "app_instance_path": (
self.config.get(app, "instance_path")), self.config.get("modoboa", "instance_path")),
"uwsgi_socket_path": ( "uwsgi_socket_path": (
Uwsgi(self.config, self.upgrade, self.restore).get_socket_path(app)) Uwsgi(self.config, self.upgrade, self.restore).get_socket_path("modoboa")
)
}) })
return context return context
@@ -34,9 +35,10 @@ class Nginx(base.Installer):
"""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()
context.update({"hostname": hostname, "extra_config": extra_config}) 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))
group = None
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
dst = os.path.join( dst = os.path.join(
self.config_dir, "sites-available", "{}.conf".format(hostname)) self.config_dir, "sites-available", "{}.conf".format(hostname))
@@ -46,7 +48,8 @@ class Nginx(base.Installer):
if os.path.exists(link): if os.path.exists(link):
return return
os.symlink(dst, link) os.symlink(dst, link)
group = self.config.get(app, "user") if self.config.has_section(app):
group = self.config.get(app, "user")
user = "www-data" user = "www-data"
else: else:
dst = os.path.join( dst = os.path.join(
@@ -54,25 +57,17 @@ class Nginx(base.Installer):
utils.copy_from_template(src, dst, context) utils.copy_from_template(src, dst, context)
group = "uwsgi" group = "uwsgi"
user = "nginx" user = "nginx"
system.add_user_to_group(user, group) if user and group:
system.add_user_to_group(user, group)
def post_run(self): def post_run(self):
"""Additionnal tasks.""" """Additionnal tasks."""
extra_modoboa_config = "" extra_modoboa_config = ""
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("autoconfig", 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"): if self.config.get("radicale", "enabled"):
extra_modoboa_config += """ extra_modoboa_config += """
location /radicale/ { location /radicale/ {

View File

@@ -20,8 +20,8 @@ class Postwhite(base.Installer):
] ]
no_daemon = True no_daemon = True
packages = { packages = {
"deb": ["bind9-host"], "deb": ["bind9-host", "unzip"],
"rpm": ["bind-utils"] "rpm": ["bind-utils", "unzip"]
} }
def install_from_archive(self, repository, target_dir): def install_from_archive(self, repository, target_dir):

View File

@@ -36,16 +36,16 @@ class Radicale(base.Installer):
"Radicale", "pytz", "radicale-modoboa-auth-oauth2" "Radicale", "pytz", "radicale-modoboa-auth-oauth2"
] ]
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(
"radicale-storage-by-index",
"https://github.com/tonioo/RadicaleStorageByIndex",
venv=self.venv_path, sudo_user=self.user)
def get_template_context(self): def get_template_context(self):
"""Additional variables.""" """Additional variables."""
context = super().get_template_context() context = super().get_template_context()
oauth2_client_id, oauth2_client_secret = utils.create_oauth2_app( oauth2_client_id, oauth2_client_secret = utils.create_oauth2_app(
"Radicale", "radicale", self.config) "Radicale",
"radicale",
self.config.get("radicale", "oauth2_client_secret"),
self.config
)
hostname = self.config.get("general", "hostname") hostname = self.config.get("general", "hostname")
oauth2_introspection_url = ( oauth2_introspection_url = (
f"https://{oauth2_client_id}:{oauth2_client_secret}" f"https://{oauth2_client_id}:{oauth2_client_secret}"

View File

@@ -83,24 +83,9 @@ class Uwsgi(base.Installer):
utils.exec_cmd( utils.exec_cmd(
"perl -pi -e '{}' /etc/uwsgi.ini".format(pattern)) "perl -pi -e '{}' /etc/uwsgi.ini".format(pattern))
def _setup_automx_config(self):
"""Custom automx configuration."""
dst = self._setup_config("automx")
if package.backend.FORMAT == "deb":
self._enable_config_debian(dst)
else:
system.add_user_to_group(
"uwsgi", self.config.get("automx", "user"))
pattern = (
"s/emperor-tyrant = true/emperor-tyrant = false/")
utils.exec_cmd(
"perl -pi -e '{}' /etc/uwsgi.ini".format(pattern))
def post_run(self): def post_run(self):
"""Additionnal tasks.""" """Additionnal tasks."""
self._setup_modoboa_config() self._setup_modoboa_config()
if self.config.getboolean("automx", "enabled"):
self._setup_automx_config()
def restart_daemon(self): def restart_daemon(self):
"""Restart daemon process.""" """Restart daemon process."""

View File

@@ -13,7 +13,6 @@ import stat
import string import string
import subprocess import subprocess
import sys import sys
import uuid
from . import config_dict_template from . import config_dict_template
from .compatibility_matrix import APP_INCOMPATIBILITY from .compatibility_matrix import APP_INCOMPATIBILITY
@@ -515,14 +514,13 @@ def validate_backup_path(path: str, silent_mode: bool):
return backup_path return backup_path
def create_oauth2_app(app_name: str, client_id: str, config) -> tuple[str, str]: def create_oauth2_app(app_name: str, client_id: str, client_secret: str, config) -> tuple[str, str]:
"""Create a application for Oauth2 authentication.""" """Create a application for Oauth2 authentication."""
# FIXME: how can we check that application already exists ? # FIXME: how can we check that application already exists ?
venv_path = config.get("modoboa", "venv_path") venv_path = config.get("modoboa", "venv_path")
python_path = os.path.join(venv_path, "bin", "python") python_path = os.path.join(venv_path, "bin", "python")
instance_path = config.get("modoboa", "instance_path") instance_path = config.get("modoboa", "instance_path")
script_path = os.path.join(instance_path, "manage.py") script_path = os.path.join(instance_path, "manage.py")
client_secret = str(uuid.uuid4())
cmd = ( cmd = (
f"{python_path} {script_path} createapplication " f"{python_path} {script_path} createapplication "
f"--name={app_name} --skip-authorization " f"--name={app_name} --skip-authorization "

1
run.py
View File

@@ -22,7 +22,6 @@ from modoboa_installer import disclaimers
PRIMARY_APPS = [ PRIMARY_APPS = [
"fail2ban", "fail2ban",
"modoboa", "modoboa",
"automx",
"radicale", "radicale",
"uwsgi", "uwsgi",
"nginx", "nginx",

View File

@@ -126,7 +126,7 @@ 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 " "fail2ban modoboa amavis clamav dovecot nginx "
"postfix postwhite spamassassin uwsgi radicale opendkim", "postfix postwhite spamassassin uwsgi radicale opendkim",
out.getvalue() out.getvalue()
) )

View File

@@ -1 +1 @@
506b59cda748708242cadaa831aa84aa320e1cfc 03b124501ec1a61eaa3063ac9fb839fdbc64f00c