9 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
13 changed files with 412 additions and 627 deletions

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):
@@ -71,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
@@ -85,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
@@ -107,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

@@ -31,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
@@ -50,19 +50,19 @@ class Installer:
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
@@ -73,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
@@ -86,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):
@@ -102,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."""
@@ -119,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,
} }
@@ -172,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):
@@ -213,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

@@ -43,7 +43,6 @@ class Dovecot(base.Installer):
"conf.d/10-master.conf", "conf.d/10-master.conf",
"conf.d/10-ssl.conf", "conf.d/10-ssl.conf",
"conf.d/10-ssl-keys.try", "conf.d/10-ssl-keys.try",
"conf.d/15-mailboxes.conf",
"conf.d/20-lmtp.conf", "conf.d/20-lmtp.conf",
"conf.d/auth-oauth2.conf.ext", "conf.d/auth-oauth2.conf.ext",
], ],
@@ -270,7 +269,7 @@ class Dovecot(base.Installer):
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):

View File

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

View File

@@ -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", "unzip"],
"rpm": ["bind-utils", "unzip"]
}
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,24 +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)
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):
@@ -67,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):
@@ -87,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__":