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, os.path.dirname(path)) backup("spamassassin", self.config, self.base_backup_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,14 +153,16 @@ 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, "db_driver": self.db_driver,
"mailboxes_owner_uid": pw_mailbox[2], "mailboxes_owner_uid": pw_mailbox[2],
"mailboxes_owner_gid": pw_mailbox[3], "mailboxes_owner_gid": pw_mailbox[3],
@@ -165,11 +175,16 @@ class Dovecot(base.Installer):
"ssl_protocols": ssl_protocols, "ssl_protocols": ssl_protocols,
"ssl_protocol_parameter": ssl_protocol_parameter, "ssl_protocol_parameter": ssl_protocol_parameter,
"modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#", "modoboa_2_2_or_greater": "" if self.modoboa_2_2_or_greater else "#",
"not_modoboa_2_2_or_greater": "" if not self.modoboa_2_2_or_greater else "#", "not_modoboa_2_2_or_greater": (
"do_move_spam_to_junk": "" if self.app_config["move_spam_to_junk"] else "#", "" if not self.modoboa_2_2_or_greater else "#"
),
"do_move_spam_to_junk": (
"" if self.app_config["move_spam_to_junk"] else "#"
),
"oauth2_introspection_url": oauth2_introspection_url, "oauth2_introspection_url": oauth2_introspection_url,
"radicale_user": self.config.get("radicale", "user"), "radicale_user": self.config.get("radicale", "user"),
}) }
)
return context return context
def install_config_files(self): def install_config_files(self):
@@ -177,9 +192,13 @@ 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()
@@ -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

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

@@ -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,6 +48,7 @@ class Nginx(base.Installer):
if os.path.exists(link): if os.path.exists(link):
return return
os.symlink(dst, link) os.symlink(dst, link)
if self.config.has_section(app):
group = self.config.get(app, "user") group = self.config.get(app, "user")
user = "www-data" user = "www-data"
else: else:
@@ -54,6 +57,7 @@ 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"
if user and group:
system.add_user_to_group(user, group) system.add_user_to_group(user, group)
def post_run(self): def post_run(self):

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_driver": self.db_driver,
"db_name": self.config.get("modoboa", "dbname"), "db_name": self.config.get("modoboa", "dbname"),
"db_user": self.app_config["dbuser"], "db_user": self.app_config["dbuser"],
"db_password": self.app_config["dbpassword"], "db_password": self.app_config["dbpassword"],
"port": self.app_config["port"], "port": self.app_config["port"],
"user": self.app_config["user"] "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(
"Error setting rspamd password. "
"Please make sure it is not 'q1' or 'q2'." "Please make sure it is not 'q1' or 'q2'."
"Storing the password in plain. See" "Storing the password in plain. See"
"https://rspamd.com/doc/quickstart.html#setting-the-controller-password") "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(
f"Following option(s) from section: {section}, "
"will not be ported due to being " "will not be ported due to being "
"deleted or renamed: " + "deleted or renamed: " + ", ".join(dropped_options),
', '.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

196
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(
"--stop-after-configfile-check", action="store_true", default=False,
help="Check configuration, generate it if needed and exit")
parser.add_argument(
"--interactive", action="store_true", default=False,
help="Generate configuration file with user interaction")
parser.add_argument(
"--upgrade", action="store_true", default=False,
help="Run the installer in upgrade mode")
parser.add_argument(
"--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( parser.add_argument(
"--silent-backup", action="store_true", default=False, "--force", action="store_true", default=False, help="Force installation"
)
parser.add_argument(
"--configfile", default="installer.cfg", help="Configuration file to use"
)
parser.add_argument(
"--version",
default="latest",
choices=versions,
help="Modoboa version to install",
)
parser.add_argument(
"--stop-after-configfile-check",
action="store_true",
default=False,
help="Check configuration, generate it if needed and exit",
)
parser.add_argument(
"--interactive",
action="store_true",
default=False,
help="Generate configuration file with user interaction",
)
parser.add_argument(
"--upgrade",
action="store_true",
default=False,
help="Run the installer in upgrade mode",
)
parser.add_argument(
"--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(
"--no-mail", action="store_true", default=False,
help="Disable mail backup (save space)")
parser.add_argument(
"--restore", type=str, metavar="path",
help="Restore a previously backup up modoboa instance on a NEW machine. "
"You MUST provide backup directory"
) )
parser.add_argument( parser.add_argument(
"--skip-checks", action="store_true", default=False, "--no-mail",
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="Disable mail backup (save space)",
)
parser.add_argument(
"--restore",
type=str,
metavar="path",
help="Restore a previously backup up modoboa instance on a NEW machine. "
"You MUST provide backup directory",
)
parser.add_argument(
"--skip-checks",
action="store_true",
default=False,
help="Skip the checks the installer performs initially",
)
parser.add_argument(
"domain", type=str, help="The main domain of your future mail server"
)
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