35 Commits

Author SHA1 Message Date
Spitap
8045f41718 Added rspamd redis db backup 2025-11-01 18:13:52 +01:00
Spitap
24ca07f762 Fixed copytree complains 2025-11-01 17:47:52 +01:00
Spitap
39e78649aa Default gid & uid for folder creation 2025-11-01 17:41:02 +01:00
Spitap
a81cd4196c Alway create the backup directory 2025-11-01 17:33:54 +01:00
Spitap
d6c70fba1d Use only the basename 2025-11-01 17:27:12 +01:00
Spitap
9c0052c274 Fixed file path for rspamd 2025-11-01 17:23:53 +01:00
Spitap
3e5b9ab310 Improved backups 2025-11-01 17:18:22 +01:00
Adrien P
30b9393877 Do not initiate backup if the section is not enabled 2025-10-20 22:34:05 +02:00
Adrien P
5887274ba8 Enable rspamd backup 2025-10-20 22:28:18 +02: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
github-actions[bot]
ff485f0d25 [GitHub Action] Updated version file 2025-09-16 13:43:42 +00:00
20 changed files with 584 additions and 625 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

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

@@ -9,8 +9,7 @@ from .. import utils
def load_app_script(appname): def load_app_script(appname):
"""Load module corresponding to the given appname.""" """Load module corresponding to the given appname."""
try: try:
script = importlib.import_module( script = importlib.import_module("modoboa_installer.scripts.{}".format(appname))
"modoboa_installer.scripts.{}".format(appname))
except ImportError: except ImportError:
print("Unknown application {}".format(appname)) print("Unknown application {}".format(appname))
sys.exit(1) sys.exit(1)
@@ -19,8 +18,9 @@ def load_app_script(appname):
def install(appname: str, config, upgrade: bool, archive_path: str): def install(appname: str, config, upgrade: bool, archive_path: str):
"""Install an application.""" """Install an application."""
if (config.has_option(appname, "enabled") and if config.has_option(appname, "enabled") and not config.getboolean(
not config.getboolean(appname, "enabled")): appname, "enabled"
):
return return
utils.printcolor("Installing {}".format(appname), utils.MAGENTA) utils.printcolor("Installing {}".format(appname), utils.MAGENTA)
@@ -34,8 +34,9 @@ def install(appname: str, config, upgrade: bool, archive_path: str):
def backup(appname, config, path): def backup(appname, config, path):
"""Backup an application.""" """Backup an application."""
if (config.has_option(appname, "enabled") and if config.has_option(appname, "enabled") and not config.getboolean(
not config.getboolean(appname, "enabled")): appname, "enabled"
):
return return
utils.printcolor("Backing up {}".format(appname), utils.MAGENTA) utils.printcolor("Backing up {}".format(appname), utils.MAGENTA)
@@ -49,6 +50,5 @@ def backup(appname, config, path):
def restore_prep(restore): def restore_prep(restore):
"""Restore instance""" """Restore instance"""
script = importlib.import_module( script = importlib.import_module("modoboa_installer.scripts.restore")
"modoboa_installer.scripts.restore")
getattr(script, "Restore")(restore) getattr(script, "Restore")(restore)

View File

@@ -10,18 +10,29 @@ from . import backup, install
class Amavis(base.Installer): class Amavis(base.Installer):
"""Amavis installer.""" """Amavis installer."""
appname = "amavis" appname = "amavis"
packages = { packages = {
"deb": [ "deb": [
"libdbi-perl", "amavisd-new", "arc", "arj", "cabextract", "libdbi-perl",
"liblz4-tool", "lrzip", "lzop", "p7zip-full", "rpm2cpio", "amavisd-new",
"arc",
"arj",
"cabextract",
"liblz4-tool",
"lrzip",
"lzop",
"p7zip-full",
"rpm2cpio",
"unrar-free", "unrar-free",
], ],
"rpm": [ "rpm": [
"amavisd-new", "arj", "lz4", "lzop", "p7zip", "amavisd-new",
"arj",
"lz4",
"lzop",
"p7zip",
], ],
} }
with_db = True with_db = True
@@ -43,8 +54,10 @@ class Amavis(base.Installer):
"""Return appropriate config files.""" """Return appropriate config files."""
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
return [ return [
"conf.d/05-node_id", "conf.d/15-content_filter_mode", "conf.d/05-node_id",
"conf.d/50-user"] "conf.d/15-content_filter_mode",
"conf.d/50-user",
]
return ["amavisd.conf"] return ["amavisd.conf"]
def get_packages(self): def get_packages(self):
@@ -52,7 +65,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":
@@ -61,9 +84,9 @@ class Amavis(base.Installer):
raise NotImplementedError("DB driver not supported") raise NotImplementedError("DB driver not supported")
packages += ["perl-DBD-{}".format(db_driver)] packages += ["perl-DBD-{}".format(db_driver)]
name, version = utils.dist_info() name, version = utils.dist_info()
if version.startswith('7'): if version.startswith("7"):
packages += ["cabextract", "lrzip", "unar", "unzoo"] packages += ["cabextract", "lrzip", "unar", "unzoo"]
elif version.startswith('8'): elif version.startswith("8"):
packages += ["perl-IO-stringy"] packages += ["perl-IO-stringy"]
return packages return packages
@@ -75,12 +98,10 @@ class Amavis(base.Installer):
version = package.backend.get_installed_version("amavis") version = package.backend.get_installed_version("amavis")
if version is None: if version is None:
raise utils.FatalError("Amavis is not installed") raise utils.FatalError("Amavis is not installed")
path = self.get_file_path( path = self.get_file_path("amavis_{}_{}.sql".format(self.dbengine, version))
"amavis_{}_{}.sql".format(self.dbengine, version))
if not os.path.exists(path): if not os.path.exists(path):
version = ".".join(version.split(".")[:-1]) + ".X" version = ".".join(version.split(".")[:-1]) + ".X"
path = self.get_file_path( path = self.get_file_path("amavis_{}_{}.sql".format(self.dbengine, version))
"amavis_{}_{}.sql".format(self.dbengine, version))
if not os.path.exists(path): if not os.path.exists(path):
raise utils.FatalError("Failed to find amavis database schema") raise utils.FatalError("Failed to find amavis database schema")
return path return path
@@ -97,20 +118,21 @@ class Amavis(base.Installer):
def custom_backup(self, path): def custom_backup(self, path):
"""Backup custom configuration if any.""" """Backup custom configuration if any."""
if package.backend.FORMAT == "deb": amavis_custom = f"{self.config_dir}/conf.d/99-custom"
amavis_custom = f"{self.config_dir}/conf.d/99-custom" if os.path.isfile(amavis_custom):
if os.path.isfile(amavis_custom): utils.copy_file(amavis_custom, path)
utils.copy_file(amavis_custom, path) utils.success("Amavis custom configuration saved!")
utils.success("Amavis custom configuration saved!") backup("spamassassin", self.config, self.base_backup_path)
backup("spamassassin", self.config, os.path.dirname(path))
def restore(self): def restore(self):
"""Restore custom config files.""" """Restore custom config files."""
if package.backend.FORMAT != "deb": if package.backend.FORMAT != "deb":
return return
amavis_custom_configuration = os.path.join( amavis_custom_configuration = os.path.join(
self.archive_path, "custom/99-custom") self.archive_path, "custom/amavis/99-custom"
)
if os.path.isfile(amavis_custom_configuration): if os.path.isfile(amavis_custom_configuration):
utils.copy_file(amavis_custom_configuration, os.path.join( utils.copy_file(
self.config_dir, "conf.d")) amavis_custom_configuration, os.path.join(self.config_dir, "conf.d")
)
utils.success("Custom amavis configuration restored.") utils.success("Custom amavis configuration restored.")

View File

@@ -1,226 +0,0 @@
"""Backup script for pre-installed instance."""
import os
import pwd
import shutil
import stat
import sys
import datetime
from .. import database
from .. import utils
from ..constants import DEFAULT_BACKUP_DIRECTORY
class Backup:
"""
Backup structure ( {optional} ):
{{backup_directory}}
||
||--> installer.cfg
||--> custom
|--> { (copy of) /etc/amavis/conf.d/99-custom }
|--> { (copy of) /etc/postfix/custom_whitelist.cidr }
|--> { (copy of) dkim directory }
|--> {dkim.pem}...
|--> { (copy of) radicale home_dir }
||--> databases
|--> modoboa.sql
|--> { amavis.sql }
|--> { spamassassin.sql }
||--> mails
|--> vmails
"""
def __init__(self, config, silent_backup, backup_path, nomail):
self.config = config
self.backup_path = backup_path
self.nomail = nomail
self.silent_backup = silent_backup
def validate_path(self, path):
"""Check basic condition for backup directory."""
path_exists = os.path.exists(path)
if path_exists and os.path.isfile(path):
utils.error("Error, you provided a file instead of a directory!")
return False
if not path_exists:
if not self.silent_backup:
create_dir = input(
f"\"{path}\" doesn't exist, would you like to create it? [Y/n]\n").lower()
if self.silent_backup or (not self.silent_backup and create_dir.startswith("y")):
pw = pwd.getpwnam("root")
utils.mkdir_safe(path, stat.S_IRWXU |
stat.S_IRWXG, pw[2], pw[3])
else:
utils.error("Error, backup directory not present.")
return False
if len(os.listdir(path)) != 0:
if not self.silent_backup:
delete_dir = input(
"Warning: backup directory is not empty, it will be purged if you continue... [Y/n]\n").lower()
if self.silent_backup or (not self.silent_backup and delete_dir.startswith("y")):
try:
os.remove(os.path.join(path, "installer.cfg"))
except FileNotFoundError:
pass
shutil.rmtree(os.path.join(path, "custom"),
ignore_errors=False)
shutil.rmtree(os.path.join(path, "mails"), ignore_errors=False)
shutil.rmtree(os.path.join(path, "databases"),
ignore_errors=False)
else:
utils.error("Error: backup directory not clean.")
return False
self.backup_path = path
pw = pwd.getpwnam("root")
for dir in ["custom/", "databases/"]:
utils.mkdir_safe(os.path.join(self.backup_path, dir),
stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
return True
def set_path(self):
"""Setup backup directory."""
if self.silent_backup:
if self.backup_path is None:
if self.config.has_option("backup", "default_path"):
path = self.config.get("backup", "default_path")
else:
path = DEFAULT_BACKUP_DIRECTORY
date = datetime.datetime.now().strftime("%m_%d_%Y_%H_%M")
path = os.path.join(path, f"backup_{date}")
self.validate_path(path)
else:
if not self.validate_path(self.backup_path):
utils.printcolor(
f"Path provided: {self.backup_path}", utils.BLUE)
sys.exit(1)
else:
user_value = None
while user_value == "" or user_value is None or not self.validate_path(user_value):
utils.printcolor(
"Enter backup path (it must be an empty directory)", utils.MAGENTA)
utils.printcolor("CTRL+C to cancel", utils.MAGENTA)
user_value = utils.user_input("-> ")
def config_file_backup(self):
utils.copy_file("installer.cfg", self.backup_path)
def mail_backup(self):
if self.nomail:
utils.printcolor(
"Skipping mail backup, no-mail argument provided", utils.MAGENTA)
return
utils.printcolor("Backing up mails", utils.MAGENTA)
home_path = self.config.get("dovecot", "home_dir")
if not os.path.exists(home_path) or os.path.isfile(home_path):
utils.error("Error backing up Email, provided path "
f" ({home_path}) seems not right...")
else:
dst = os.path.join(self.backup_path, "mails/")
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(home_path, dst)
utils.printcolor("Mail backup complete!", utils.GREEN)
def custom_config_backup(self):
"""
Custom config :
- DKIM keys: {{keys_storage_dir}}
- Radicale collection (calendars, contacts): {{home_dir}}
- Amavis : /etc/amavis/conf.d/99-custom
- Postwhite : /etc/postwhite.conf
Feel free to suggest to add others!
"""
utils.printcolor(
"Backing up some custom configuration...", utils.MAGENTA)
custom_path = os.path.join(
self.backup_path, "custom")
# DKIM Key
if (self.config.has_option("opendkim", "enabled") and
self.config.getboolean("opendkim", "enabled")):
dkim_keys = self.config.get(
"opendkim", "keys_storage_dir", fallback="/var/lib/dkim")
if os.path.isdir(dkim_keys):
shutil.copytree(dkim_keys, os.path.join(custom_path, "dkim"))
utils.printcolor(
"DKIM keys saved!", utils.GREEN)
# Radicale Collections
if (self.config.has_option("radicale", "enabled") and
self.config.getboolean("radicale", "enabled")):
radicale_backup = os.path.join(self.config.get(
"radicale", "home_dir", fallback="/srv/radicale"), "collections")
if os.path.isdir(radicale_backup):
shutil.copytree(radicale_backup, os.path.join(
custom_path, "radicale"))
utils.printcolor("Radicale files saved", utils.GREEN)
# AMAVIS
if (self.config.has_option("amavis", "enabled") and
self.config.getboolean("amavis", "enabled")):
amavis_custom = "/etc/amavis/conf.d/99-custom"
if os.path.isfile(amavis_custom):
utils.copy_file(amavis_custom, custom_path)
utils.printcolor(
"Amavis custom configuration saved!", utils.GREEN)
# POSTWHITE
if (self.config.has_option("postwhite", "enabled") and
self.config.getboolean("postwhite", "enabled")):
postswhite_custom = "/etc/postwhite.conf"
if os.path.isfile(postswhite_custom):
utils.copy_file(postswhite_custom, custom_path)
utils.printcolor(
"Postwhite configuration saved!", utils.GREEN)
def database_backup(self):
"""Backing up databases"""
utils.printcolor("Backing up databases...", utils.MAGENTA)
self.database_dump("modoboa")
self.database_dump("amavis")
self.database_dump("spamassassin")
def database_dump(self, app_name):
dump_path = os.path.join(self.backup_path, "databases")
backend = database.get_backend(self.config)
if app_name == "modoboa" or (self.config.has_option(app_name, "enabled") and
self.config.getboolean(app_name, "enabled")):
dbname = self.config.get(app_name, "dbname")
dbuser = self.config.get(app_name, "dbuser")
dbpasswd = self.config.get(app_name, "dbpassword")
backend.dump_database(dbname, dbuser, dbpasswd,
os.path.join(dump_path, f"{app_name}.sql"))
def backup_completed(self):
utils.printcolor("Backup process done, your backup is available here:"
f"--> {self.backup_path}", utils.GREEN)
def run(self):
self.set_path()
self.config_file_backup()
self.mail_backup()
self.custom_config_backup()
self.database_backup()
self.backup_completed()

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
@@ -30,12 +31,12 @@ class Installer:
self.app_config = dict(self.config.items(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 = "pgsql" if self.dbengine == "postgres" else self.dbengine
"pgsql" if self.dbengine == "postgres" else self.dbengine)
self.backend = database.get_backend(self.config) self.backend = database.get_backend(self.config)
self.dbhost = self.config.get("database", "host") self.dbhost = self.config.get("database", "host")
self.dbport = self.config.get( self.dbport = self.config.get(
"database", "port", fallback=self.backend.default_port) "database", "port", fallback=self.backend.default_port
)
self._config_dir = None self._config_dir = None
if not self.with_db: if not self.with_db:
return return
@@ -44,24 +45,24 @@ 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",
self.config.get("modoboa", "venv_path"), self.config.get("modoboa", "venv_path"),
sudo_user=self.config.get("modoboa", "user") sudo_user=self.config.get("modoboa", "user"),
) )
condition = ( condition = (
(int(modoboa_version[0]) == 2 and int(modoboa_version[1]) >= 2) or int(modoboa_version[0]) == 2 and int(modoboa_version[1]) >= 2
int(modoboa_version[0]) > 2 ) or int(modoboa_version[0]) > 2
)
return condition return condition
@property @property
def config_dir(self): def config_dir(self):
"""Return main configuration directory.""" """Return main configuration directory."""
if self._config_dir is None and self.config.has_option( if self._config_dir is None and self.config.has_option(
self.appname, "config_dir"): self.appname, "config_dir"
):
self._config_dir = self.config.get(self.appname, "config_dir") self._config_dir = self.config.get(self.appname, "config_dir")
return self._config_dir return self._config_dir
@@ -72,11 +73,11 @@ class Installer:
def get_sql_schema_from_backup(self): def get_sql_schema_from_backup(self):
"""Retrieve a dump path from a previous backup.""" """Retrieve a dump path from a previous backup."""
utils.printcolor( utils.printcolor(
f"Trying to restore {self.appname} database from backup.", f"Trying to restore {self.appname} database from backup.", utils.MAGENTA
utils.MAGENTA
) )
database_backup_path = os.path.join( database_backup_path = os.path.join(
self.archive_path, f"databases/{self.appname}.sql") self.archive_path, f"databases/{self.appname}.sql"
)
if os.path.isfile(database_backup_path): if os.path.isfile(database_backup_path):
utils.success(f"SQL dump found in backup for {self.appname}!") utils.success(f"SQL dump found in backup for {self.appname}!")
return database_backup_path return database_backup_path
@@ -85,8 +86,7 @@ class Installer:
def get_file_path(self, fname): def get_file_path(self, fname):
"""Return the absolute path of this file.""" """Return the absolute path of this file."""
return os.path.abspath( return os.path.abspath(
os.path.join( os.path.join(os.path.dirname(__file__), "files", self.appname, fname)
os.path.dirname(__file__), "files", self.appname, fname)
) )
def setup_database(self): def setup_database(self):
@@ -101,8 +101,7 @@ class Installer:
if not schema: if not schema:
schema = self.get_sql_schema_path() schema = self.get_sql_schema_path()
if schema: if schema:
self.backend.load_sql_file( self.backend.load_sql_file(self.dbname, self.dbuser, self.dbpasswd, schema)
self.dbname, self.dbuser, self.dbpasswd, schema)
def setup_user(self): def setup_user(self):
"""Setup a system user.""" """Setup a system user."""
@@ -118,8 +117,7 @@ class Installer:
def get_template_context(self): def get_template_context(self):
"""Return context used for template rendering.""" """Return context used for template rendering."""
context = { context = {
"dbengine": ( "dbengine": ("Pg" if self.dbengine == "postgres" else self.dbengine),
"Pg" if self.dbengine == "postgres" else self.dbengine),
"dbhost": self.dbhost, "dbhost": self.dbhost,
"dbport": self.dbport, "dbport": self.dbport,
} }
@@ -171,9 +169,11 @@ class Installer:
utils.copy_from_template(src, dst, context) utils.copy_from_template(src, dst, context)
def backup(self, path): def backup(self, path):
self.base_backup_path = path
if self.with_db: if self.with_db:
self._dump_database(path) self._dump_database(path)
custom_backup_path = os.path.join(path, "custom") custom_backup_path = os.path.join(path, "custom", self.appname)
utils.mkdir_safe(custom_backup_path)
self.custom_backup(custom_backup_path) self.custom_backup(custom_backup_path)
def custom_backup(self, path): def custom_backup(self, path):
@@ -212,8 +212,7 @@ class Installer:
"""Create a new database dump for this app.""" """Create a new database dump for this app."""
target_dir = os.path.join(backup_path, "databases") target_dir = os.path.join(backup_path, "databases")
target_file = os.path.join(target_dir, f"{self.appname}.sql") target_file = os.path.join(target_dir, f"{self.appname}.sql")
self.backend.dump_database( self.backend.dump_database(self.dbname, self.dbuser, self.dbpasswd, target_file)
self.dbname, self.dbuser, self.dbpasswd, target_file)
def pre_run(self): def pre_run(self):
"""Tasks to execute before the installer starts.""" """Tasks to execute before the installer starts."""

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": [
@@ -45,9 +44,8 @@ class Dovecot(base.Installer):
"conf.d/10-ssl.conf", "conf.d/10-ssl.conf",
"conf.d/10-ssl-keys.try", "conf.d/10-ssl-keys.try",
"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 +67,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 +106,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 +119,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 +129,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 +153,38 @@ 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
)
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 +192,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 +210,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 +233,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 +251,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,14 +260,16 @@ 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/")
if os.path.exists(dst): if os.path.exists(dst):
shutil.rmtree(dst) shutil.rmtree(dst)
shutil.copytree(home_dir, dst) shutil.copytree(home_dir, dst, dirs_exist_ok=True)
utils.success("Mail backup complete!") utils.success("Mail backup complete!")
def restore(self): def restore(self):
@@ -257,10 +285,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

@@ -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

@@ -92,7 +92,7 @@ oauth2_introspection_endpoint = %{oauth2_introspection_url}
# Rights backend # Rights backend
# Value: none | authenticated | owner_only | owner_write | from_file # Value: none | authenticated | owner_only | owner_write | from_file
type = from_file type = from_file
# File for rights management from_file # File for rights management from_file
file = %{config_dir}/rights file = %{config_dir}/rights
@@ -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
@@ -134,7 +132,7 @@ filesystem_folder = %{home_dir}/collections
# Web interface backend # Web interface backend
# Value: none | internal # Value: none | internal
type = none type = none
[logging] [logging]

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,7 +57,8 @@ 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."""

View File

@@ -16,10 +16,7 @@ class Opendkim(base.Installer):
"""OpenDKIM installer.""" """OpenDKIM installer."""
appname = "opendkim" appname = "opendkim"
packages = { packages = {"deb": ["opendkim"], "rpm": ["opendkim"]}
"deb": ["opendkim"],
"rpm": ["opendkim"]
}
config_files = ["opendkim.conf", "opendkim.hosts"] config_files = ["opendkim.conf", "opendkim.hosts"]
def get_packages(self): def get_packages(self):
@@ -36,30 +33,34 @@ class Opendkim(base.Installer):
"""Make sure config directory exists.""" """Make sure config directory exists."""
user = self.config.get("opendkim", "user") user = self.config.get("opendkim", "user")
pw = pwd.getpwnam(user) pw = pwd.getpwnam(user)
targets = [ targets = [[self.app_config["keys_storage_dir"], pw[2], pw[3]]]
[self.app_config["keys_storage_dir"], pw[2], pw[3]]
]
for target in targets: for target in targets:
if not os.path.exists(target[0]): if not os.path.exists(target[0]):
utils.mkdir( utils.mkdir(
target[0], target[0],
stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IRWXU
stat.S_IROTH | stat.S_IXOTH, | stat.S_IRGRP
target[1], target[2] | stat.S_IXGRP
| stat.S_IROTH
| stat.S_IXOTH,
target[1],
target[2],
) )
super().install_config_files() super().install_config_files()
def get_template_context(self): def get_template_context(self):
"""Additional variables.""" """Additional variables."""
context = super(Opendkim, self).get_template_context() context = super(Opendkim, self).get_template_context()
context.update({ context.update(
"db_driver": self.db_driver, {
"db_name": self.config.get("modoboa", "dbname"), "db_driver": self.db_driver,
"db_user": self.app_config["dbuser"], "db_name": self.config.get("modoboa", "dbname"),
"db_password": self.app_config["dbpassword"], "db_user": self.app_config["dbuser"],
"port": self.app_config["port"], "db_password": self.app_config["dbpassword"],
"user": self.app_config["user"] "port": self.app_config["port"],
}) "user": self.app_config["user"],
}
)
return context return context
def setup_database(self): def setup_database(self):
@@ -72,11 +73,14 @@ class Opendkim(base.Installer):
dbuser = self.config.get("modoboa", "dbuser") dbuser = self.config.get("modoboa", "dbuser")
dbpassword = self.config.get("modoboa", "dbpassword") dbpassword = self.config.get("modoboa", "dbpassword")
self.backend.load_sql_file( self.backend.load_sql_file(
dbname, dbuser, dbpassword, dbname,
self.get_file_path("dkim_view_{}.sql".format(self.dbengine)) dbuser,
dbpassword,
self.get_file_path("dkim_view_{}.sql".format(self.dbengine)),
) )
self.backend.grant_right_on_table( self.backend.grant_right_on_table(
dbname, "dkim", self.app_config["dbuser"], "SELECT") dbname, "dkim", self.app_config["dbuser"], "SELECT"
)
def post_run(self): def post_run(self):
"""Additional tasks. """Additional tasks.
@@ -90,31 +94,33 @@ class Opendkim(base.Installer):
else: else:
params_file = "/etc/opendkim.conf" params_file = "/etc/opendkim.conf"
pattern = r"s/^(SOCKET=.*)/#\1/" pattern = r"s/^(SOCKET=.*)/#\1/"
utils.exec_cmd( utils.exec_cmd("perl -pi -e '{}' {}".format(pattern, params_file))
"perl -pi -e '{}' {}".format(pattern, params_file))
with open(params_file, "a") as f: with open(params_file, "a") as f:
f.write('\n'.join([ f.write(
"", "\n".join(
'SOCKET="inet:12345@localhost"', [
])) "",
'SOCKET="inet:12345@localhost"',
]
)
)
# Make sure opendkim is started after postgresql and mysql, # Make sure opendkim is started after postgresql and mysql,
# respectively. # respectively.
if (self.dbengine != "postgres" and package.backend.FORMAT == "deb"): if self.dbengine != "postgres" and package.backend.FORMAT == "deb":
dbservice = "mysql.service" dbservice = "mysql.service"
elif (self.dbengine != "postgres" and package.backend.FORMAT != "deb"): elif self.dbengine != "postgres" and package.backend.FORMAT != "deb":
dbservice = "mysqld.service" dbservice = "mysqld.service"
else: else:
dbservice = "postgresql.service" dbservice = "postgresql.service"
pattern = ( pattern = "s/^After=(.*)$/After=$1 {}/".format(dbservice)
"s/^After=(.*)$/After=$1 {}/".format(dbservice))
utils.exec_cmd( utils.exec_cmd(
"perl -pi -e '{}' /lib/systemd/system/opendkim.service".format(pattern)) "perl -pi -e '{}' /lib/systemd/system/opendkim.service".format(pattern)
)
def restore(self): def restore(self):
"""Restore keys.""" """Restore keys."""
dkim_keys_backup = os.path.join( dkim_keys_backup = os.path.join(self.archive_path, "custom/opendkim")
self.archive_path, "custom/dkim")
keys_storage_dir = self.app_config["keys_storage_dir"] keys_storage_dir = self.app_config["keys_storage_dir"]
if os.path.isdir(dkim_keys_backup): if os.path.isdir(dkim_keys_backup):
for file in os.listdir(dkim_keys_backup): for file in os.listdir(dkim_keys_backup):
@@ -129,6 +135,7 @@ class Opendkim(base.Installer):
def custom_backup(self, path): def custom_backup(self, path):
"""Backup DKIM keys.""" """Backup DKIM keys."""
if os.path.isdir(self.app_config["keys_storage_dir"]): if os.path.isdir(self.app_config["keys_storage_dir"]):
shutil.copytree(self.app_config["keys_storage_dir"], os.path.join(path, "dkim")) shutil.copytree(
utils.printcolor( self.app_config["keys_storage_dir"], path, dirs_exist_ok=True
"DKIM keys saved!", utils.GREEN) )
utils.printcolor("DKIM keys saved!", utils.GREEN)

View File

@@ -19,10 +19,7 @@ class Postwhite(base.Installer):
"crontab=/etc/cron.d/postwhite", "crontab=/etc/cron.d/postwhite",
] ]
no_daemon = True no_daemon = True
packages = { packages = {"deb": ["bind9-host", "unzip"], "rpm": ["bind-utils", "unzip"]}
"deb": ["bind9-host"],
"rpm": ["bind-utils"]
}
def install_from_archive(self, repository, target_dir): def install_from_archive(self, repository, target_dir):
"""Install from an archive.""" """Install from an archive."""
@@ -36,8 +33,7 @@ class Postwhite(base.Installer):
if os.path.exists(archive_dir): if os.path.exists(archive_dir):
shutil.rmtree(archive_dir) shutil.rmtree(archive_dir)
utils.exec_cmd("unzip master.zip", cwd=target_dir) utils.exec_cmd("unzip master.zip", cwd=target_dir)
utils.exec_cmd( utils.exec_cmd("mv {name}-master {name}".format(name=app_name), cwd=target_dir)
"mv {name}-master {name}".format(name=app_name), cwd=target_dir)
os.unlink(target) os.unlink(target)
return archive_dir return archive_dir
@@ -45,10 +41,8 @@ class Postwhite(base.Installer):
"""Additionnal tasks.""" """Additionnal tasks."""
install_dir = "/usr/local/bin" install_dir = "/usr/local/bin"
self.install_from_archive(SPF_TOOLS_REPOSITORY, install_dir) self.install_from_archive(SPF_TOOLS_REPOSITORY, install_dir)
self.postw_dir = self.install_from_archive( self.postw_dir = self.install_from_archive(POSTWHITE_REPOSITORY, install_dir)
POSTWHITE_REPOSITORY, install_dir) utils.copy_file(os.path.join(self.postw_dir, "postwhite.conf"), self.config_dir)
utils.copy_file(
os.path.join(self.postw_dir, "postwhite.conf"), self.config_dir)
self.postw_bin = os.path.join(self.postw_dir, "postwhite") self.postw_bin = os.path.join(self.postw_dir, "postwhite")
utils.exec_cmd("{} /etc/postwhite.conf".format(self.postw_bin)) utils.exec_cmd("{} /etc/postwhite.conf".format(self.postw_bin))
@@ -57,13 +51,13 @@ class Postwhite(base.Installer):
postswhite_custom = "/etc/postwhite.conf" postswhite_custom = "/etc/postwhite.conf"
if os.path.isfile(postswhite_custom): if os.path.isfile(postswhite_custom):
utils.copy_file(postswhite_custom, path) utils.copy_file(postswhite_custom, path)
utils.printcolor( utils.printcolor("Postwhite configuration saved!", utils.GREEN)
"Postwhite configuration saved!", utils.GREEN)
def restore(self): def restore(self):
"""Restore config files.""" """Restore config files."""
postwhite_backup_configuration = os.path.join( postwhite_backup_configuration = os.path.join(
self.archive_path, "custom/postwhite.conf") self.archive_path, "custom/postwhite/postwhite.conf"
)
if os.path.isfile(postwhite_backup_configuration): if os.path.isfile(postwhite_backup_configuration):
utils.copy_file(postwhite_backup_configuration, self.config_dir) utils.copy_file(postwhite_backup_configuration, self.config_dir)
utils.success("postwhite.conf restored from backup") utils.success("postwhite.conf restored from backup")

View File

@@ -18,10 +18,7 @@ class Radicale(base.Installer):
appname = "radicale" appname = "radicale"
config_files = ["config"] config_files = ["config"]
no_daemon = True no_daemon = True
packages = { packages = {"deb": ["supervisor"], "rpm": ["supervisor"]}
"deb": ["supervisor"],
"rpm": ["supervisor"]
}
with_user = True with_user = True
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -32,28 +29,25 @@ class Radicale(base.Installer):
def _setup_venv(self): def _setup_venv(self):
"""Prepare a dedicated virtualenv.""" """Prepare a dedicated virtualenv."""
python.setup_virtualenv(self.venv_path, sudo_user=self.user) python.setup_virtualenv(self.venv_path, sudo_user=self.user)
packages = [ packages = ["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
)
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(
"oauth2_introspection_url": oauth2_introspection_url, {
}) "oauth2_introspection_url": oauth2_introspection_url,
}
)
return context return context
def get_config_files(self): def get_config_files(self):
@@ -71,16 +65,19 @@ class Radicale(base.Installer):
if not os.path.exists(self.config_dir): if not os.path.exists(self.config_dir):
utils.mkdir( utils.mkdir(
self.config_dir, self.config_dir,
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 restore(self): def restore(self):
"""Restore collections.""" """Restore collections."""
radicale_backup = os.path.join( radicale_backup = os.path.join(self.archive_path, "custom/radicale")
self.archive_path, "custom/radicale")
if os.path.isdir(radicale_backup): if os.path.isdir(radicale_backup):
restore_target = os.path.join(self.home_dir, "collections") restore_target = os.path.join(self.home_dir, "collections")
if os.path.isdir(restore_target): if os.path.isdir(restore_target):
@@ -91,18 +88,17 @@ class Radicale(base.Installer):
def post_run(self): def post_run(self):
"""Additional tasks.""" """Additional tasks."""
self._setup_venv() self._setup_venv()
daemon_name = ( daemon_name = "supervisor" if package.backend.FORMAT == "deb" else "supervisord"
"supervisor" if package.backend.FORMAT == "deb" else "supervisord"
)
system.enable_service(daemon_name) system.enable_service(daemon_name)
utils.exec_cmd("service {} stop".format(daemon_name)) utils.exec_cmd("service {} stop".format(daemon_name))
utils.exec_cmd("service {} start".format(daemon_name)) utils.exec_cmd("service {} start".format(daemon_name))
def custom_backup(self, path): def custom_backup(self, path):
"""Backup collections.""" """Backup collections."""
radicale_backup = os.path.join(self.config.get( radicale_backup = os.path.join(
"radicale", "home_dir", fallback="/srv/radicale"), "collections") self.config.get("radicale", "home_dir", fallback="/srv/radicale"),
"collections",
)
if os.path.isdir(radicale_backup): if os.path.isdir(radicale_backup):
shutil.copytree(radicale_backup, os.path.join( shutil.copytree(radicale_backup, path, dirs_exist_ok=True)
path, "radicale"))
utils.printcolor("Radicale files saved", utils.GREEN) utils.printcolor("Radicale files saved", utils.GREEN)

View File

@@ -16,11 +16,7 @@ class Rspamd(base.Installer):
"""Rspamd installer.""" """Rspamd installer."""
appname = "rspamd" appname = "rspamd"
packages = { packages = {"deb": ["rspamd", "redis"]}
"deb": [
"rspamd", "redis"
]
}
config_files = [ config_files = [
"local.d/arc.conf", "local.d/arc.conf",
"local.d/dkim_signing.conf", "local.d/dkim_signing.conf",
@@ -39,10 +35,9 @@ class Rspamd(base.Installer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.generate_password_condition = ( self.generate_password_condition = not self.upgrade or utils.user_input(
not self.upgrade or utils.user_input( "Do you want to (re)generate rspamd password ? (y/N)"
"Do you want to (re)generate rspamd password ? (y/N)").lower().startswith("y") ).lower().startswith("y")
)
@property @property
def config_dir(self): def config_dir(self):
@@ -54,16 +49,20 @@ class Rspamd(base.Installer):
if debian_based_dist: if debian_based_dist:
utils.mkdir_safe( utils.mkdir_safe(
"/etc/apt/keyrings", "/etc/apt/keyrings",
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,
) )
package.backend.add_custom_repository( package.backend.add_custom_repository(
"rspamd", "rspamd",
"http://rspamd.com/apt-stable/", "http://rspamd.com/apt-stable/",
"https://rspamd.com/apt-stable/gpg.key", "https://rspamd.com/apt-stable/gpg.key",
codename codename,
) )
package.backend.update() package.backend.update()
@@ -73,16 +72,18 @@ class Rspamd(base.Installer):
"""Make sure config directory exists.""" """Make sure config directory exists."""
user = self.config.get(self.appname, "user") user = self.config.get(self.appname, "user")
pw = pwd.getpwnam(user) pw = pwd.getpwnam(user)
targets = [ targets = [[self.app_config["dkim_keys_storage_dir"], pw[2], pw[3]]]
[self.app_config["dkim_keys_storage_dir"], pw[2], pw[3]]
]
for target in targets: for target in targets:
if not os.path.exists(target[0]): if not os.path.exists(target[0]):
utils.mkdir( utils.mkdir(
target[0], target[0],
stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IRWXU
stat.S_IROTH | stat.S_IXOTH, | stat.S_IRGRP
target[1], target[2] | stat.S_IXGRP
| stat.S_IROTH
| stat.S_IXOTH,
target[1],
target[2],
) )
super().install_config_files() super().install_config_files()
@@ -101,16 +102,23 @@ class Rspamd(base.Installer):
def get_template_context(self): def get_template_context(self):
_context = super().get_template_context() _context = super().get_template_context()
_context["greylisting_disabled"] = "" if not self.app_config["greylisting"].lower() == "true" else "#" _context["greylisting_disabled"] = (
_context["whitelist_auth_enabled"] = "" if self.app_config["whitelist_auth"].lower() == "true" else "#" "" if not self.app_config["greylisting"].lower() == "true" else "#"
)
_context["whitelist_auth_enabled"] = (
"" if self.app_config["whitelist_auth"].lower() == "true" else "#"
)
if self.generate_password_condition: if self.generate_password_condition:
code, controller_password = utils.exec_cmd( code, controller_password = utils.exec_cmd(
r"rspamadm pw -p {}".format(self.app_config["password"])) r"rspamadm pw -p {}".format(self.app_config["password"])
)
if code != 0: if code != 0:
utils.error("Error setting rspamd password. " utils.error(
"Please make sure it is not 'q1' or 'q2'." "Error setting rspamd password. "
"Storing the password in plain. See" "Please make sure it is not 'q1' or 'q2'."
"https://rspamd.com/doc/quickstart.html#setting-the-controller-password") "Storing the password in plain. See"
"https://rspamd.com/doc/quickstart.html#setting-the-controller-password"
)
_context["controller_password"] = self.app_config["password"] _context["controller_password"] = self.app_config["password"]
else: else:
controller_password = controller_password.decode().replace("\n", "") controller_password = controller_password.decode().replace("\n", "")
@@ -120,35 +128,59 @@ class Rspamd(base.Installer):
def post_run(self): def post_run(self):
"""Additional tasks.""" """Additional tasks."""
user = self.config.get(self.appname, "user") user = self.config.get(self.appname, "user")
system.add_user_to_group( system.add_user_to_group(self.config.get("modoboa", "user"), user)
self.config.get("modoboa", "user"),
user
)
if self.config.getboolean("clamav", "enabled"): if self.config.getboolean("clamav", "enabled"):
install("clamav", self.config, self.upgrade, self.archive_path) install("clamav", self.config, self.upgrade, self.archive_path)
def backup(self, path):
self._dump_database(path)
super().backup(path)
def _dump_database(self, backup_path: str):
"""Copy the rrd file containing the redis db for rspamd.
Uses the default path on debian 13,
it may be somewhere else on other distros..."""
rspamd_redis_db = "/var/lib/rspamd/rspamd.rrd"
if not os.path.isfile(rspamd_redis_db):
return
target_dir = os.path.join(backup_path, "databases")
target_file = os.path.join(target_dir, "rspamd.rrd")
system.stop_service("redis")
utils.copy_file(rspamd_redis_db, target_file)
system.restart_service("redis")
def custom_backup(self, path): def custom_backup(self, path):
"""Backup custom configuration if any.""" """Backup custom configuration if any."""
custom_config_dir = os.path.join(self.config_dir, custom_config_dir = os.path.join(self.config_dir, "local.d/")
"/local.d/") local_files = [
custom_backup_dir = os.path.join(path, "/rspamd/") os.path.join(custom_config_dir, f)
local_files = [f for f in os.listdir(custom_config_dir) for f in os.listdir(custom_config_dir)
if os.path.isfile(custom_config_dir, f) if os.path.isfile(os.path.join(custom_config_dir, f))
] ]
backup_locald_path = os.path.join(path, "local.d")
if local_files:
utils.mkdir_safe(backup_locald_path)
for file in local_files: for file in local_files:
utils.copy_file(file, custom_backup_dir) basename = os.path.basename(file)
if len(local_files) != 0: utils.copy_file(file, os.path.join(backup_locald_path, basename))
if local_files:
utils.success("Rspamd custom configuration saved!") utils.success("Rspamd custom configuration saved!")
def restore(self): def restore(self):
"""Restore custom config files.""" """Restore custom config files."""
custom_config_dir = os.path.join(self.config_dir, custom_config_dir = os.path.join(self.config_dir, "/local.d/")
"/local.d/")
custom_backup_dir = os.path.join(self.archive_path, "/rspamd/") custom_backup_dir = os.path.join(self.archive_path, "/rspamd/")
backed_up_files = [ backed_up_files = [
f for f in os.listdir(custom_backup_dir) f
for f in os.listdir(custom_backup_dir)
if os.path.isfile(custom_backup_dir, f) if os.path.isfile(custom_backup_dir, f)
] ]
for f in backed_up_files: for f in backed_up_files:
utils.copy_file(f, custom_config_dir) utils.copy_file(f, custom_config_dir)
rspamd_redis_db = "/var/lib/rspamd"
rspamd_redis_db_backup = os.path.join(self.archive_path, "databases/rspamd.rrd")
if os.path.isdir(rspamd_redis_db) and os.path.isfile(rspamd_redis_db_backup):
system.stop_service("redis")
utils.copy_file(rspamd_redis_db_backup, rspamd_redis_db)
system.restart_service("redis")
utils.success("Custom Rspamd configuration restored.") utils.success("Custom Rspamd configuration restored.")

View File

@@ -16,12 +16,13 @@ def create_user(name, home=None):
else: else:
extra_message = "." extra_message = "."
if home: if home:
extra_message = ( extra_message = " but please make sure the {} directory exists.".format(
" but please make sure the {} directory exists.".format( home
home)) )
utils.printcolor( utils.printcolor(
"User {} already exists, skipping creation{}".format( "User {} already exists, skipping creation{}".format(name, extra_message),
name, extra_message), utils.YELLOW) utils.YELLOW,
)
return return
cmd = "useradd -m " cmd = "useradd -m "
if home: if home:
@@ -62,3 +63,8 @@ def enable_and_start_service(name):
def restart_service(name): def restart_service(name):
"""Restart a service.""" """Restart a service."""
utils.exec_cmd("service {} restart".format(name)) utils.exec_cmd("service {} restart".format(name))
def stop_service(name):
"""Stop a service."""
utils.exec_cmd(f"service {name} stop")

View File

@@ -24,7 +24,6 @@ BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8)
class FatalError(Exception): class FatalError(Exception):
"""A simple exception.""" """A simple exception."""
pass pass
@@ -77,16 +76,14 @@ def dist_info():
info = {} info = {}
with open(path) as fp: with open(path) as fp:
for line in fp.readlines(): for line in fp.readlines():
if line == '\n': if line == "\n":
continue continue
key, value = line.split("=") key, value = line.split("=")
value = value.rstrip('"\n') value = value.rstrip('"\n')
value = value.strip('"') value = value.strip('"')
info[key] = value info[key] = value
return info["NAME"], info["VERSION_ID"] return info["NAME"], info["VERSION_ID"]
printcolor( printcolor("Failed to retrieve information about your system, aborting.", RED)
"Failed to retrieve information about your system, aborting.",
RED)
sys.exit(1) sys.exit(1)
@@ -99,14 +96,27 @@ def is_dist_debian_based() -> (bool, str):
"""Check if current OS is Debian based or not.""" """Check if current OS is Debian based or not."""
status, codename = exec_cmd("lsb_release -c -s") status, codename = exec_cmd("lsb_release -c -s")
codename = codename.decode().strip().lower() codename = codename.decode().strip().lower()
return codename in [ return (
"bionic", "bookworm", "bullseye", "buster", codename
"focal", "jammy", "jessie", "sid", "stretch", in [
"trusty", "wheezy", "xenial" "bionic",
], codename "bookworm",
"bullseye",
"buster",
"focal",
"jammy",
"jessie",
"sid",
"stretch",
"trusty",
"wheezy",
"xenial",
],
codename,
)
def mkdir(path, mode, uid, gid): def mkdir(path, mode=0o777, uid=os.getuid(), gid=os.getgid()):
"""Create a directory.""" """Create a directory."""
if not os.path.exists(path): if not os.path.exists(path):
os.mkdir(path, mode) os.mkdir(path, mode)
@@ -115,7 +125,7 @@ def mkdir(path, mode, uid, gid):
os.chown(path, uid, gid) os.chown(path, uid, gid)
def mkdir_safe(path, mode, uid, gid): def mkdir_safe(path, mode=0o777, uid=os.getuid(), gid=os.getgid()):
"""Create a directory. Safe way (-p)""" """Create a directory. Safe way (-p)"""
if not os.path.exists(path): if not os.path.exists(path):
os.makedirs(os.path.abspath(path), mode) os.makedirs(os.path.abspath(path), mode)
@@ -125,8 +135,9 @@ def mkdir_safe(path, mode, uid, gid):
def make_password(length=16): def make_password(length=16):
"""Create a random password.""" """Create a random password."""
return "".join( return "".join(
random.SystemRandom().choice( random.SystemRandom().choice(string.ascii_letters + string.digits)
string.ascii_letters + string.digits) for _ in range(length)) for _ in range(length)
)
@contextlib.contextmanager @contextlib.contextmanager
@@ -149,8 +160,7 @@ def backup_file(fname):
"""Create a backup of a given file.""" """Create a backup of a given file."""
for f in glob.glob("{}.old.*".format(fname)): for f in glob.glob("{}.old.*".format(fname)):
os.unlink(f) os.unlink(f)
bak_name = "{}.old.{}".format( bak_name = "{}.old.{}".format(fname, datetime.datetime.now().isoformat())
fname, datetime.datetime.now().isoformat())
shutil.copy(fname, bak_name) shutil.copy(fname, bak_name)
@@ -171,17 +181,13 @@ def copy_from_template(template, dest, context):
if os.path.isfile(dest): if os.path.isfile(dest):
backup_file(dest) backup_file(dest)
with open(dest, "w") as fp: with open(dest, "w") as fp:
fp.write( fp.write("# This file was automatically installed on {}\n".format(now))
"# This file was automatically installed on {}\n"
.format(now))
fp.write(ConfigFileTemplate(buf).substitute(context)) fp.write(ConfigFileTemplate(buf).substitute(context))
def check_config_file(dest, def check_config_file(
interactive=False, dest, interactive=False, upgrade=False, backup=False, restore=False
upgrade=False, ):
backup=False,
restore=False):
"""Create a new installer config file if needed.""" """Create a new installer config file if needed."""
is_present = True is_present = True
if os.path.exists(dest): if os.path.exists(dest):
@@ -189,22 +195,25 @@ def check_config_file(dest,
if upgrade: if upgrade:
error( error(
"You cannot upgrade an existing installation without a " "You cannot upgrade an existing installation without a "
"configuration file.") "configuration file."
)
sys.exit(1) sys.exit(1)
elif backup: elif backup:
is_present = False is_present = False
error( error(
"Your configuration file hasn't been found. A new one will be generated. " "Your configuration file hasn't been found. A new one will be generated. "
"Please edit it with correct password for the databases !") "Please edit it with correct password for the databases !"
)
elif restore: elif restore:
error( error(
"You cannot restore an existing installation without a " "You cannot restore an existing installation without a "
f"configuration file. (file : {dest} has not been found...") f"configuration file. (file : {dest} has not been found..."
)
sys.exit(1) sys.exit(1)
printcolor( printcolor(
"Configuration file {} not found, creating new one." "Configuration file {} not found, creating new one.".format(dest), YELLOW
.format(dest), YELLOW) )
gen_config(dest, interactive) gen_config(dest, interactive)
return is_present, None return is_present, None
@@ -217,6 +226,7 @@ def has_colours(stream):
return False # auto color only on TTYs return False # auto color only on TTYs
try: try:
import curses import curses
curses.setupterm() curses.setupterm()
return curses.tigetnum("colors") > 2 return curses.tigetnum("colors") > 2
except: except:
@@ -251,8 +261,9 @@ def convert_version_to_int(version):
numbers = [int(number_string) for number_string in version.split(".")] numbers = [int(number_string) for number_string in version.split(".")]
if len(numbers) > len(number_bits): if len(numbers) > len(number_bits):
raise NotImplementedError( raise NotImplementedError(
"Versions with more than {0} decimal places are not supported" "Versions with more than {0} decimal places are not supported".format(
.format(len(number_bits) - 1) len(number_bits) - 1
)
) )
# add 0s for missing numbers # add 0s for missing numbers
numbers.extend([0] * (len(number_bits) - len(numbers))) numbers.extend([0] * (len(number_bits) - len(numbers)))
@@ -263,8 +274,9 @@ def convert_version_to_int(version):
max_num = (bits + 1) - 1 max_num = (bits + 1) - 1
if num >= 1 << max_num: if num >= 1 << max_num:
raise ValueError( raise ValueError(
"Number {0} cannot be stored with only {1} bits. Max is {2}" "Number {0} cannot be stored with only {1} bits. Max is {2}".format(
.format(num, bits, max_num) num, bits, max_num
)
) )
number += num << total_bits number += num << total_bits
total_bits += bits total_bits += bits
@@ -315,7 +327,9 @@ def validate(value, config_entry):
return True return True
def get_entry_value(entry: dict, interactive: bool, config: configparser.ConfigParser) -> string: def get_entry_value(
entry: dict, interactive: bool, config: configparser.ConfigParser
) -> string:
default_entry = entry["default"] default_entry = entry["default"]
if type(default_entry) is type(list()): if type(default_entry) is type(list()):
default_value = str(check_if_condition(config, default_entry)).lower() default_value = str(check_if_condition(config, default_entry)).lower()
@@ -325,7 +339,7 @@ def get_entry_value(entry: dict, interactive: bool, config: configparser.ConfigP
default_value = default_entry default_value = default_entry
user_value = None user_value = None
if entry.get("customizable") and interactive: if entry.get("customizable") and interactive:
while (user_value != '' and not validate(user_value, entry)): while user_value != "" and not validate(user_value, entry):
question = entry.get("question") question = entry.get("question")
if entry.get("values"): if entry.get("values"):
question += " from the list" question += " from the list"
@@ -366,14 +380,10 @@ def load_config_template(interactive):
config.add_section(section["name"]) config.add_section(section["name"])
for config_entry in section["values"]: for config_entry in section["values"]:
if config_entry.get("if") is not None: if config_entry.get("if") is not None:
interactive_section = (interactive_section and interactive_section = interactive_section and check_if_condition(
check_if_condition( config, config_entry["if"]
config, config_entry["if"] )
) value = get_entry_value(config_entry, interactive_section, config)
)
value = get_entry_value(config_entry,
interactive_section,
config)
config.set(section["name"], config_entry["option"], value) config.set(section["name"], config_entry["option"], value)
return config return config
@@ -393,10 +403,11 @@ def update_config(path, apply_update=True):
dropped_sections = list(set(old_sections) - set(new_sections)) dropped_sections = list(set(old_sections) - set(new_sections))
added_sections = list(set(new_sections) - set(old_sections)) added_sections = list(set(new_sections) - set(old_sections))
if len(dropped_sections) > 0 and apply_update: if len(dropped_sections) > 0 and apply_update:
printcolor("Following section(s) will not be ported " printcolor(
"due to being deleted or renamed: " + "Following section(s) will not be ported "
', '.join(dropped_sections), "due to being deleted or renamed: " + ", ".join(dropped_sections),
RED) RED,
)
if len(dropped_sections) + len(added_sections) > 0: if len(dropped_sections) + len(added_sections) > 0:
update = True update = True
@@ -409,11 +420,12 @@ def update_config(path, apply_update=True):
dropped_options = list(set(old_options) - set(new_options)) dropped_options = list(set(old_options) - set(new_options))
added_options = list(set(new_options) - set(old_options)) added_options = list(set(new_options) - set(old_options))
if len(dropped_options) > 0 and apply_update: if len(dropped_options) > 0 and apply_update:
printcolor(f"Following option(s) from section: {section}, " printcolor(
"will not be ported due to being " f"Following option(s) from section: {section}, "
"deleted or renamed: " + "will not be ported due to being "
', '.join(dropped_options), "deleted or renamed: " + ", ".join(dropped_options),
RED) RED,
)
if len(dropped_options) + len(added_options) > 0: if len(dropped_options) + len(added_options) > 0:
update = True update = True
@@ -466,29 +478,27 @@ def validate_backup_path(path: str, silent_mode: bool):
"""Check if provided backup path is valid or not.""" """Check if provided backup path is valid or not."""
path_exists = os.path.exists(path) path_exists = os.path.exists(path)
if path_exists and os.path.isfile(path): if path_exists and os.path.isfile(path):
printcolor( printcolor("Error, you provided a file instead of a directory!", RED)
"Error, you provided a file instead of a directory!", RED)
return None return None
if not path_exists: if not path_exists:
if not silent_mode: if not silent_mode:
create_dir = input( create_dir = input(
f"\"{path}\" doesn't exist, would you like to create it? [y/N]\n" f'"{path}" doesn\'t exist, would you like to create it? [y/N]\n'
).lower() ).lower()
if silent_mode or (not silent_mode and create_dir.startswith("y")): if silent_mode or (not silent_mode and create_dir.startswith("y")):
pw = pwd.getpwnam("root") pw = pwd.getpwnam("root")
mkdir_safe(path, stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3]) mkdir_safe(path, stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
else: else:
printcolor( printcolor("Error, backup directory not present.", RED)
"Error, backup directory not present.", RED
)
return None return None
if len(os.listdir(path)) != 0: if len(os.listdir(path)) != 0:
if not silent_mode: if not silent_mode:
delete_dir = input( delete_dir = input(
"Warning: backup directory is not empty, it will be purged if you continue... [y/N]\n").lower() "Warning: backup directory is not empty, it will be purged if you continue... [y/N]\n"
).lower()
if silent_mode or (not silent_mode and delete_dir.startswith("y")): if silent_mode or (not silent_mode and delete_dir.startswith("y")):
try: try:
@@ -496,22 +506,19 @@ def validate_backup_path(path: str, silent_mode: bool):
except FileNotFoundError: except FileNotFoundError:
pass pass
shutil.rmtree(os.path.join(path, "custom"), shutil.rmtree(os.path.join(path, "custom"), ignore_errors=False)
ignore_errors=False)
shutil.rmtree(os.path.join(path, "mails"), ignore_errors=False) shutil.rmtree(os.path.join(path, "mails"), ignore_errors=False)
shutil.rmtree(os.path.join(path, "databases"), shutil.rmtree(os.path.join(path, "databases"), ignore_errors=False)
ignore_errors=False)
else: else:
printcolor( printcolor("Error: backup directory not clean.", RED)
"Error: backup directory not clean.", RED
)
return None return None
backup_path = path backup_path = path
pw = pwd.getpwnam("root") pw = pwd.getpwnam("root")
for dir in ["custom/", "databases/"]: for dir in ["custom/", "databases/"]:
mkdir_safe(os.path.join(backup_path, dir), mkdir_safe(
stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3]) os.path.join(backup_path, dir), stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3]
)
return backup_path return backup_path
@@ -539,7 +546,9 @@ def check_app_compatibility(section, config):
if section in APP_INCOMPATIBILITY.keys(): if section in APP_INCOMPATIBILITY.keys():
for app in APP_INCOMPATIBILITY[section]: for app in APP_INCOMPATIBILITY[section]:
if config.getboolean(app, "enabled"): if config.getboolean(app, "enabled"):
error(f"{section} cannot be installed if {app} is enabled. " error(
"Please disable one of them.") f"{section} cannot be installed if {app} is enabled. "
"Please disable one of them."
)
incompatible_app.append(app) incompatible_app.append(app)
return len(incompatible_app) == 0 return len(incompatible_app) == 0

182
run.py
View File

@@ -26,11 +26,11 @@ PRIMARY_APPS = [
"uwsgi", "uwsgi",
"nginx", "nginx",
"postfix", "postfix",
"dovecot" "dovecot",
] ]
def backup_system(config, args): def backup_system(config, args, antispam_apps):
"""Launch backup procedure.""" """Launch backup procedure."""
disclaimers.backup_disclaimer() disclaimers.backup_disclaimer()
backup_path = None backup_path = None
@@ -52,8 +52,7 @@ def backup_system(config, args):
user_value = None user_value = None
while not user_value or not backup_path: while not user_value or not backup_path:
utils.printcolor( utils.printcolor(
"Enter backup path (it must be an empty directory)", "Enter backup path (it must be an empty directory)", utils.MAGENTA
utils.MAGENTA
) )
utils.printcolor("CTRL+C to cancel", utils.MAGENTA) utils.printcolor("CTRL+C to cancel", utils.MAGENTA)
user_value = utils.user_input("-> ") user_value = utils.user_input("-> ")
@@ -64,7 +63,9 @@ def backup_system(config, args):
# Backup configuration file # Backup configuration file
utils.copy_file(args.configfile, backup_path) utils.copy_file(args.configfile, backup_path)
# Backup applications # Backup applications
for app in PRIMARY_APPS: for app in PRIMARY_APPS + antispam_apps:
if config.has_option(app, "enabled") and not config.getboolean(app, "enabled"):
continue
if app == "dovecot" and args.no_mail: if app == "dovecot" and args.no_mail:
utils.printcolor("Skipping mail backup", utils.BLUE) utils.printcolor("Skipping mail backup", utils.BLUE)
continue continue
@@ -72,65 +73,97 @@ def backup_system(config, args):
def config_file_update_complete(backup_location): def config_file_update_complete(backup_location):
utils.printcolor("Update complete. It seems successful.", utils.printcolor("Update complete. It seems successful.", utils.BLUE)
utils.BLUE)
if backup_location is not None: if backup_location is not None:
utils.printcolor("You will find your old config file " utils.printcolor(
f"here: {backup_location}", "You will find your old config file " f"here: {backup_location}", utils.BLUE
utils.BLUE) )
def parser_setup(input_args): def parser_setup(input_args):
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
versions = ( versions = ["latest"] + list(compatibility_matrix.COMPATIBILITY_MATRIX.keys())
["latest"] + list(compatibility_matrix.COMPATIBILITY_MATRIX.keys())
)
parser.add_argument("--debug", action="store_true", default=False,
help="Enable debug output")
parser.add_argument("--force", action="store_true", default=False,
help="Force installation")
parser.add_argument("--configfile", default="installer.cfg",
help="Configuration file to use")
parser.add_argument( parser.add_argument(
"--version", default="latest", choices=versions, "--debug", action="store_true", default=False, help="Enable debug output"
help="Modoboa version to install") )
parser.add_argument( parser.add_argument(
"--stop-after-configfile-check", action="store_true", default=False, "--force", action="store_true", default=False, help="Force installation"
help="Check configuration, generate it if needed and exit") )
parser.add_argument( parser.add_argument(
"--interactive", action="store_true", default=False, "--configfile", default="installer.cfg", help="Configuration file to use"
help="Generate configuration file with user interaction") )
parser.add_argument( parser.add_argument(
"--upgrade", action="store_true", default=False, "--version",
help="Run the installer in upgrade mode") default="latest",
choices=versions,
help="Modoboa version to install",
)
parser.add_argument( parser.add_argument(
"--beta", action="store_true", default=False, "--stop-after-configfile-check",
help="Install latest beta release of Modoboa instead of the stable one") action="store_true",
default=False,
help="Check configuration, generate it if needed and exit",
)
parser.add_argument( parser.add_argument(
"--backup-path", type=str, metavar="path", "--interactive",
help="To use with --silent-backup, you must provide a valid path") action="store_true",
default=False,
help="Generate configuration file with user interaction",
)
parser.add_argument( parser.add_argument(
"--backup", action="store_true", default=False, "--upgrade",
help="Backing up interactively previously installed instance" action="store_true",
) default=False,
help="Run the installer in upgrade mode",
)
parser.add_argument( parser.add_argument(
"--silent-backup", action="store_true", default=False, "--beta",
action="store_true",
default=False,
help="Install latest beta release of Modoboa instead of the stable one",
)
parser.add_argument(
"--backup-path",
type=str,
metavar="path",
help="To use with --silent-backup, you must provide a valid path",
)
parser.add_argument(
"--backup",
action="store_true",
default=False,
help="Backing up interactively previously installed instance",
)
parser.add_argument(
"--silent-backup",
action="store_true",
default=False,
help="For script usage, do not require user interaction " help="For script usage, do not require user interaction "
"backup will be saved at ./modoboa_backup/Backup_M_Y_d_H_M " "backup will be saved at ./modoboa_backup/Backup_M_Y_d_H_M "
"if --backup-path is not provided") "if --backup-path is not provided",
)
parser.add_argument( parser.add_argument(
"--no-mail", action="store_true", default=False, "--no-mail",
help="Disable mail backup (save space)") action="store_true",
default=False,
help="Disable mail backup (save space)",
)
parser.add_argument( parser.add_argument(
"--restore", type=str, metavar="path", "--restore",
type=str,
metavar="path",
help="Restore a previously backup up modoboa instance on a NEW machine. " help="Restore a previously backup up modoboa instance on a NEW machine. "
"You MUST provide backup directory" "You MUST provide backup directory",
) )
parser.add_argument( parser.add_argument(
"--skip-checks", action="store_true", default=False, "--skip-checks",
help="Skip the checks the installer performs initially") action="store_true",
parser.add_argument("domain", type=str, default=False,
help="The main domain of your future mail server") help="Skip the checks the installer performs initially",
)
parser.add_argument(
"domain", type=str, help="The main domain of your future mail server"
)
return parser.parse_args(input_args) return parser.parse_args(input_args)
@@ -147,9 +180,7 @@ def main(input_args):
is_restoring = True is_restoring = True
args.configfile = os.path.join(args.restore, args.configfile) args.configfile = os.path.join(args.restore, args.configfile)
if not os.path.exists(args.configfile): if not os.path.exists(args.configfile):
utils.error( utils.error("Installer configuration file not found in backup!")
"Installer configuration file not found in backup!"
)
sys.exit(1) sys.exit(1)
utils.success("Welcome to Modoboa installer!\n") utils.success("Welcome to Modoboa installer!\n")
@@ -161,26 +192,34 @@ def main(input_args):
utils.success("Checks complete\n") utils.success("Checks complete\n")
is_config_file_available, outdate_config = utils.check_config_file( is_config_file_available, outdate_config = utils.check_config_file(
args.configfile, args.interactive, args.upgrade, args.backup, is_restoring) args.configfile, args.interactive, args.upgrade, args.backup, is_restoring
)
if not is_config_file_available and ( if not is_config_file_available and (
args.upgrade or args.backup or args.silent_backup): args.upgrade or args.backup or args.silent_backup
):
utils.error("No config file found.") utils.error("No config file found.")
return return
# Check if config is outdated and ask user if it needs to be updated # Check if config is outdated and ask user if it needs to be updated
if is_config_file_available and outdate_config: if is_config_file_available and outdate_config:
answer = utils.user_input("It seems that your config file is outdated. " answer = utils.user_input(
"Would you like to update it? (Y/n) ") "It seems that your config file is outdated. "
"Would you like to update it? (Y/n) "
)
if not answer or answer.lower().startswith("y"): if not answer or answer.lower().startswith("y"):
config_file_update_complete(utils.update_config(args.configfile)) config_file_update_complete(utils.update_config(args.configfile))
if not args.stop_after_configfile_check: if not args.stop_after_configfile_check:
answer = utils.user_input("Would you like to stop to review the updated config? (Y/n)") answer = utils.user_input(
"Would you like to stop to review the updated config? (Y/n)"
)
if not answer or answer.lower().startswith("y"): if not answer or answer.lower().startswith("y"):
return return
else: else:
utils.error("You might encounter unexpected errors ! " utils.error(
"Make sure to update your config before opening an issue!") "You might encounter unexpected errors ! "
"Make sure to update your config before opening an issue!"
)
if args.stop_after_configfile_check: if args.stop_after_configfile_check:
return return
@@ -201,7 +240,7 @@ def main(input_args):
antispam_apps = ["rspamd"] antispam_apps = ["rspamd"]
if args.backup or args.silent_backup: if args.backup or args.silent_backup:
backup_system(config, args) backup_system(config, args, antispam_apps)
return return
# Display disclaimer python 3 linux distribution # Display disclaimer python 3 linux distribution
@@ -216,11 +255,20 @@ def main(input_args):
# Show concerned components # Show concerned components
components = [] components = []
for section in config.sections(): for section in config.sections():
if section in ["general", "antispam", "database", "mysql", "postgres", if section in [
"certificate", "letsencrypt", "backup"]: "general",
"antispam",
"database",
"mysql",
"postgres",
"certificate",
"letsencrypt",
"backup",
]:
continue continue
if (config.has_option(section, "enabled") and if config.has_option(section, "enabled") and not config.getboolean(
not config.getboolean(section, "enabled")): section, "enabled"
):
continue continue
incompatible_app_detected = not utils.check_app_compatibility(section, config) incompatible_app_detected = not utils.check_app_compatibility(section, config)
if incompatible_app_detected: if incompatible_app_detected:
@@ -233,8 +281,9 @@ def main(input_args):
return return
config.set("general", "force", str(args.force)) config.set("general", "force", str(args.force))
utils.printcolor( utils.printcolor(
"The process can be long, feel free to take a coffee " "The process can be long, feel free to take a coffee " "and come back later ;)",
"and come back later ;)", utils.BLUE) utils.BLUE,
)
utils.success("Starting...") utils.success("Starting...")
package.backend.prepare_system() package.backend.prepare_system()
package.backend.install_many(["sudo", "wget"]) package.backend.install_many(["sudo", "wget"])
@@ -268,13 +317,8 @@ def main(input_args):
"You like the project and want it to be sustainable?\n" "You like the project and want it to be sustainable?\n"
"Then don't wait anymore and go sponsor it here:\n" "Then don't wait anymore and go sponsor it here:\n"
) )
utils.printcolor( utils.printcolor("https://github.com/sponsors/modoboa\n", utils.YELLOW)
"https://github.com/sponsors/modoboa\n", utils.success("Thank you for your help :-)\n")
utils.YELLOW
)
utils.success(
"Thank you for your help :-)\n"
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1 +1 @@
506b59cda748708242cadaa831aa84aa320e1cfc 1d701353d900f4b6e2f7ffba6f6b7a46d304f58b