diff --git a/data/Dockerfiles/bootstrap/main.py b/data/Dockerfiles/bootstrap/main.py new file mode 100644 index 000000000..05cf6d5ef --- /dev/null +++ b/data/Dockerfiles/bootstrap/main.py @@ -0,0 +1,30 @@ +import os +import sys + +def main(): + container_name = os.getenv("CONTAINER_NAME") + + if container_name == "sogo-mailcow": + from modules.BootstrapSogo import Bootstrap + else: + print(f"No bootstrap handler for container: {container_name}", file=sys.stderr) + sys.exit(1) + + b = Bootstrap( + container=container_name, + db_config = { + "host": "localhost", + "user": os.getenv("DBUSER"), + "password": os.getenv("DBPASS"), + "database": os.getenv("DBNAME"), + "unix_socket": "/var/run/mysqld/mysqld.sock", + 'connection_timeout': 2 + }, + db_table="service_settings", + db_settings=['sogo'] + ) + + b.bootstrap() + +if __name__ == "__main__": + main() diff --git a/data/Dockerfiles/bootstrap/modules/BootstrapBase.py b/data/Dockerfiles/bootstrap/modules/BootstrapBase.py new file mode 100644 index 000000000..b7e95abc8 --- /dev/null +++ b/data/Dockerfiles/bootstrap/modules/BootstrapBase.py @@ -0,0 +1,456 @@ +import os +import pwd +import grp +import shutil +import secrets +import string +import subprocess +import time +import socket +import signal +import re +import json +from pathlib import Path +import mysql.connector +from jinja2 import Environment, FileSystemLoader + +class BootstrapBase: + def __init__(self, container, db_config, db_table, db_settings): + self.container = container + self.db_config = db_config + self.db_table = db_table + self.db_settings = db_settings + + self.env = None + self.env_vars = None + self.mysql_conn = None + + def render_config(self, template_name, output_path): + """ + Renders a Jinja2 template and writes it to the specified output path. + + The method uses the class's `self.env` Jinja2 environment and `self.env_vars` + for rendering template variables. + + Args: + template_name (str): Name of the template file. + output_path (str or Path): Path to write the rendered output file. + """ + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + template = self.env.get_template(template_name) + rendered = template.render(self.env_vars) + + with open(output_path, "w") as f: + f.write(rendered) + + def prepare_template_vars(self, overwrite_path, extra_vars = None): + """ + Loads and merges environment variables for Jinja2 templates from multiple sources. + + This method combines: + 1. System environment variables + 2. Key/value pairs from the MySQL `service_settings` table + 3. An optional dictionary of extra_vars + 4. A JSON file with overrides (if the file exists) + + Args: + overwrite_path (str or Path): Path to a JSON file containing key-value overrides. + extra_vars (dict, optional): A dictionary of additional variables to include. + + Returns: + dict: A dictionary containing all resolved template variables. + + Raises: + Prints errors if database fetch or JSON parsing fails, but does not raise exceptions. + """ + + # 1. Load env vars + env_vars = dict(os.environ) + + # 2. Load from MySQL + try: + cursor = self.mysql_conn.cursor() + + if self.db_settings: + placeholders = ','.join(['%s'] * len(self.db_settings)) + sql = f"SELECT `key`, `value` FROM {self.db_table} WHERE `type` IN ({placeholders})" + cursor.execute(sql, self.db_settings) + else: + cursor.execute(f"SELECT `key`, `value` FROM {self.db_table}") + + for key, value in cursor.fetchall(): + env_vars[key] = value + + cursor.close() + except Exception as e: + print(f"Failed to fetch DB service settings: {e}") + + # 3. Load extra vars + if extra_vars: + env_vars.update(extra_vars) + + # 4. Load overwrites + overwrite_path = Path(overwrite_path) + if overwrite_path.exists(): + try: + with overwrite_path.open("r") as f: + overwrite_data = json.load(f) + env_vars.update(overwrite_data) + except Exception as e: + print(f"Failed to parse overwrites: {e}") + + return env_vars + + def set_timezone(self): + """ + Sets the system timezone based on the TZ environment variable. + + If the TZ variable is set, writes its value to /etc/timezone. + """ + + timezone = os.getenv("TZ") + if timezone: + with open("/etc/timezone", "w") as f: + f.write(timezone + "\n") + + def set_syslog_redis(self): + """ + Reconfigures syslog-ng to use a Redis slave configuration. + + If the REDIS_SLAVEOF_IP environment variable is set, replaces the syslog-ng config + with the Redis slave-specific config. + """ + + redis_slave_ip = os.getenv("REDIS_SLAVEOF_IP") + if redis_slave_ip: + shutil.copy("/etc/syslog-ng/syslog-ng-redis_slave.conf", "/etc/syslog-ng/syslog-ng.conf") + + def rsync_file(self, src, dst, recursive=False, owner=None, mode=None): + """ + Copies files or directories using rsync, with optional ownership and permissions. + + Args: + src (str or Path): Source file or directory. + dst (str or Path): Destination directory. + recursive (bool): If True, copies contents recursively. + owner (tuple): Tuple of (user, group) to set ownership. + mode (int): File mode (e.g., 0o644) to set permissions after sync. + """ + + src_path = Path(src) + dst_path = Path(dst) + dst_path.mkdir(parents=True, exist_ok=True) + + rsync_cmd = ["rsync", "-a"] + if recursive: + rsync_cmd.append(str(src_path) + "/") + else: + rsync_cmd.append(str(src_path)) + rsync_cmd.append(str(dst_path)) + + try: + subprocess.run(rsync_cmd, check=True) + except Exception as e: + print(f"Rsync failed: {e}") + + if owner: + self.set_owner(dst_path, *owner, recursive=True) + if mode: + self.set_permissions(dst_path, mode) + + def set_permissions(self, path, mode): + """ + Sets file or directory permissions. + + Args: + path (str or Path): Path to the file or directory. + mode (int): File mode to apply, e.g., 0o644. + + Raises: + FileNotFoundError: If the path does not exist. + """ + + file_path = Path(path) + if not file_path.exists(): + raise FileNotFoundError(f"Cannot chmod: {file_path} does not exist") + os.chmod(file_path, mode) + + def set_owner(self, path, user, group=None, recursive=False): + """ + Changes ownership of a file or directory. + + Args: + path (str or Path): Path to the file or directory. + user (str): Username for new owner. + group (str, optional): Group name; defaults to user's group if not provided. + recursive (bool): If True and path is a directory, ownership is applied recursively. + + Raises: + FileNotFoundError: If the path does not exist. + """ + + uid = pwd.getpwnam(user).pw_uid + gid = grp.getgrnam(group or user).gr_gid + + p = Path(path) + if not p.exists(): + raise FileNotFoundError(f"{path} does not exist") + + if recursive and p.is_dir(): + for sub_path in p.rglob("*"): + os.chown(sub_path, uid, gid) + os.chown(p, uid, gid) + + def move_file(self, src, dst, overwrite=True): + """ + Moves a file from src to dst, optionally overwriting existing files. + + Args: + src (str or Path): Source file path. + dst (str or Path): Destination path. + overwrite (bool): If False, raises error if dst exists. + + Raises: + FileNotFoundError: If the source file does not exist. + FileExistsError: If the destination file exists and overwrite is False. + """ + + src_path = Path(src) + dst_path = Path(dst) + + if not src_path.exists(): + raise FileNotFoundError(f"Source file does not exist: {src}") + + dst_path.parent.mkdir(parents=True, exist_ok=True) + + if dst_path.exists() and not overwrite: + raise FileExistsError(f"Destination already exists: {dst} (set overwrite=True to overwrite)") + + shutil.move(str(src_path), str(dst_path)) + + def patch_exists(self, target_file, patch_file, reverse=False): + """ + Checks whether a patch can be applied (or reversed) to a target file. + + Args: + target_file (str): File to test the patch against. + patch_file (str): Patch file to apply. + reverse (bool): If True, checks whether the patch can be reversed. + + Returns: + bool: True if patch is applicable, False otherwise. + """ + + cmd = ["patch", "-sfN", "--dry-run", target_file, "<", patch_file] + if reverse: + cmd.insert(1, "-R") + try: + result = subprocess.run( + " ".join(cmd), + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + return result.returncode == 0 + except Exception as e: + print(f"Patch dry-run failed: {e}") + return False + + def apply_patch(self, target_file, patch_file, reverse=False): + """ + Applies a patch file to a target file. + + Args: + target_file (str): File to be patched. + patch_file (str): Patch file containing the diff. + reverse (bool): If True, applies the patch in reverse (rollback). + + Logs: + Success or failure of the patching operation. + """ + + cmd = ["patch", target_file, "<", patch_file] + if reverse: + cmd.insert(0, "-R") + try: + subprocess.run(" ".join(cmd), shell=True, check=True) + print(f"Applied patch {'(reverse)' if reverse else ''} to {target_file}") + except subprocess.CalledProcessError as e: + print(f"Patch failed: {e}") + + def isYes(self, value): + """ + Determines whether a given string represents a "yes"-like value. + + Args: + value (str): Input string to evaluate. + + Returns: + bool: True if value is "yes" or "y" (case-insensitive), otherwise False. + """ + return value.lower() in ["yes", "y"] + + def is_port_open(self, host, port): + """ + Checks whether a TCP port is open on a given host. + + Args: + host (str): The hostname or IP address to check. + port (int): The TCP port number to test. + + Returns: + bool: True if the port is open and accepting connections, False otherwise. + """ + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + result = sock.connect_ex((host, port)) + return result == 0 + + def kill_proc(self, process): + """ + Sends a SIGTERM signal to all processes matching the given name using `killall`. + + Args: + process (str): The name of the process to terminate. + + Returns: + True if the signal was sent successfully, or the subprocess error if it failed. + """ + + try: + subprocess.run(["killall", "-TERM", process], check=True) + except subprocess.CalledProcessError as e: + return e + + return True + + def connect_mysql(self): + """ + Establishes a connection to the MySQL database using the provided configuration. + + Continuously retries the connection until the database is reachable. Stores + the connection in `self.mysql_conn` once successful. + + Logs: + Connection status and retry errors to stdout. + """ + + print("Connecting to MySQL...") + + while True: + try: + self.mysql_conn = mysql.connector.connect(**self.db_config) + if self.mysql_conn.is_connected(): + print("MySQL is up and ready!") + break + except Error as e: + print(f"Waiting for MySQL... ({e})") + time.sleep(2) + + def close_mysql(self): + """ + Closes the MySQL connection if it's currently open and connected. + + Safe to call even if the connection has already been closed. + """ + + if self.mysql_conn and self.mysql_conn.is_connected(): + self.mysql_conn.close() + + def wait_for_schema_update(self, init_file_path="init_db.inc.php", check_interval=5): + """ + Waits until the current database schema version matches the expected version + defined in a PHP initialization file. + + Compares the `version` value in the `versions` table for `application = 'db_schema'` + with the `$db_version` value extracted from the specified PHP file. + + Args: + init_file_path (str): Path to the PHP file containing the expected version string. + check_interval (int): Time in seconds to wait between version checks. + + Logs: + Current vs. expected schema versions until they match. + """ + + print("Checking database schema version...") + + while True: + current_version = self._get_current_db_version() + expected_version = self._get_expected_schema_version(init_file_path) + + if current_version == expected_version: + print(f"DB schema is up to date: {current_version}") + break + + print(f"Waiting for schema update... (DB: {current_version}, Expected: {expected_version})") + time.sleep(check_interval) + + def _get_current_db_version(self): + """ + Fetches the current schema version from the database. + + Executes a SELECT query on the `versions` table where `application = 'db_schema'`. + + Returns: + str or None: The current schema version as a string, or None if not found or on error. + + Logs: + Error message if the query fails. + """ + + try: + cursor = self.mysql_conn.cursor() + cursor.execute("SELECT version FROM versions WHERE application = 'db_schema'") + result = cursor.fetchone() + cursor.close() + return result[0] if result else None + except Exception as e: + print(f"Error fetching current DB schema version: {e}") + return None + + def _get_expected_schema_version(self, filepath): + """ + Extracts the expected database schema version from a PHP initialization file. + + Looks for a line in the form of: `$db_version = "..."` and extracts the version string. + + Args: + filepath (str): Path to the PHP file containing the `$db_version` definition. + + Returns: + str or None: The extracted version string, or None if not found or on error. + + Logs: + Error message if the file cannot be read or parsed. + """ + + try: + with open(filepath, "r") as f: + content = f.read() + match = re.search(r'\$db_version\s*=\s*"([^"]+)"', content) + if match: + return match.group(1) + except Exception as e: + print(f"Error reading expected schema version from {filepath}: {e}") + return None + + def rand_pass(self, length=22): + """ + Generates a secure random password using allowed characters. + + Allowed characters include upper/lowercase letters, digits, underscores, and hyphens. + + Args: + length (int): Length of the password to generate. Default is 22. + + Returns: + str: A securely generated random password string. + """ + + allowed_chars = string.ascii_letters + string.digits + "_-" + return ''.join(secrets.choice(allowed_chars) for _ in range(length)) diff --git a/data/Dockerfiles/bootstrap/modules/BootstrapSogo.py b/data/Dockerfiles/bootstrap/modules/BootstrapSogo.py new file mode 100644 index 000000000..9ce5c8f12 --- /dev/null +++ b/data/Dockerfiles/bootstrap/modules/BootstrapSogo.py @@ -0,0 +1,136 @@ +from jinja2 import Environment, FileSystemLoader +from modules.BootstrapBase import BootstrapBase +from pathlib import Path +import os +import sys +import time + +class Bootstrap(BootstrapBase): + def bootstrap(self): + # Skip SOGo if set + if self.isYes(os.getenv("SKIP_SOGO", "")): + print("SKIP_SOGO is set, skipping SOGo startup...") + time.sleep(365 * 24 * 60 * 60) + sys.exit(1) + + # Connect to MySQL + self.connect_mysql() + + # Wait until port is free + while self.is_port_open("sogo-mailcow", 20000): + print("Port 20000 still in use — terminating sogod...") + self.kill_proc("sogod") + time.sleep(3) + + # Wait for schema to update to expected version + self.wait_for_schema_update(init_file_path="init_db.inc.php") + + # Setup Jinja2 Environment and load vars + self.env = Environment( + loader=FileSystemLoader("./etc/sogo/config_templates"), + keep_trailing_newline=True, + lstrip_blocks=False, + trim_blocks=False + ) + extra_vars = { + "SQL_DOMAINS": self.get_domains(), + "IAM_SETTINGS": self.get_identity_provider_settings() + } + self.env_vars = self.prepare_template_vars('/overwrites.json', extra_vars) + + print("Set Timezone") + self.set_timezone() + + print("Set Syslog redis") + self.set_syslog_redis() + + print("Render config") + self.render_config("sogod.plist.j2", "/var/lib/sogo/GNUstep/Defaults/sogod.plist") + self.render_config("UIxTopnavToolbar.wox.j2", "/usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox") + + print("Fix permissions") + self.set_owner("/var/lib/sogo", "sogo", "sogo", recursive=True) + self.set_permissions("/var/lib/sogo/GNUstep/Defaults/sogod.plist", 0o600) + + # Rename custom logo + logo_src = Path("/etc/sogo/sogo-full.svg") + if logo_src.exists(): + print("Set Logo") + self.move_file(logo_src, "/etc/sogo/custom-fulllogo.svg") + + # Rsync web content + print("Syncing web content") + self.rsync_file("/usr/lib/GNUstep/SOGo/", "/sogo_web/", recursive=True) + + # Chown backup path + self.set_owner("/sogo_backup", "sogo", "sogo", recursive=True) + + def get_domains(self): + """ + Retrieves a list of domains and their GAL (Global Address List) status. + + Executes a SQL query to select: + - `domain` + - a human-readable GAL status ("YES" or "NO") + - `ldap_gal` as a boolean (True/False) + + Returns: + list[dict]: A list of dicts with keys: domain, gal_status, ldap_gal. + Example: [{"domain": "example.com", "gal_status": "YES", "ldap_gal": True}] + + Logs: + Error messages if the query fails. + """ + + query = """ + SELECT domain, + CASE gal WHEN '1' THEN 'YES' ELSE 'NO' END AS gal_status, + ldap_gal = 1 AS ldap_gal + FROM domain; + """ + try: + cursor = self.mysql_conn.cursor() + cursor.execute(query) + result = cursor.fetchall() + cursor.close() + + return [ + { + "domain": row[0], + "gal_status": row[1], + "ldap_gal": bool(row[2]) + } + for row in result + ] + except Exception as e: + print(f"Error fetching domains: {e}") + return [] + + def get_identity_provider_settings(self): + """ + Retrieves all key-value identity provider settings. + + Returns: + dict: Settings in the format { key: value } + + Logs: + Error messages if the query fails. + """ + query = "SELECT `key`, `value` FROM identity_provider;" + try: + cursor = self.mysql_conn.cursor() + cursor.execute(query) + result = cursor.fetchall() + cursor.close() + + iam_settings = {row[0]: row[1] for row in result} + + if iam_settings['authsource'] == "ldap": + protocol = "ldaps" if iam_settings.get("use_ssl") else "ldap" + starttls = "/????!StartTLS" if iam_settings.get("use_tls") else "" + iam_settings['ldap_url'] = f"{protocol}://{iam_settings['host']}:{iam_settings['port']}{starttls}" + + return iam_settings + except Exception as e: + print(f"Error fetching identity provider settings: {e}") + return {} \ No newline at end of file diff --git a/data/Dockerfiles/bootstrap/modules/__init__.py b/data/Dockerfiles/bootstrap/modules/__init__.py new file mode 100644 index 000000000..e69de29bb