#!/usr/bin/env python3 # Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) # SPDX-License-Identifier: MIT """Idempotent Zabbix 7.4 deployment with PostgreSQL 18, nginx, PHP-FPM. Deploys a full Zabbix monitoring stack with TLS-secured frontend on Fedora or openEuler. Every operation checks current state before acting; running the script repeatedly produces the same result with no errors and no duplicate work. Architecture: nginx :443 (HTTPS, self-signed, IPv6 + IPv4) → PHP-FPM (Zabbix frontend) → PostgreSQL 18 (Zabbix DB) Zabbix server + agent on localhost Password handling: --db-password / --zabbix-password work but leak into shell history and `ps` output — prefer the ZABBIX_DB_PASSWORD / ZABBIX_ADMIN_PASSWORD environment variables. With no password supplied, one is generated; on re-runs the existing DBPassword from zabbix_server.conf is reused. Usage: ZABBIX_DB_PASSWORD=SECRET zabbix-deploy.py zabbix-deploy.py --db-password SECRET --zabbix-password SECRET zabbix-deploy.py --dry-run zabbix-deploy.py --uninstall zabbix-deploy.py --version """ from __future__ import annotations import argparse import os import re import secrets import subprocess import sys import time from typing import Any BOLD = "\033[1m" RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" DIM = "\033[2m" RESET = "\033[0m" VERSION = "1.0.0" SUPPORTED_OS: dict[str, str] = { "fedora": "Fedora", "openeuler": "openEuler", } # PostgreSQL paths for version 18. PG_BIN = "/usr/pgsql-18/bin" PG_DATA = "/var/lib/pgsql/18/data" PG_HBA = "/var/lib/pgsql/18/data/pg_hba.conf" PG_SERVICE = "postgresql-18" PG_SETUP = "/usr/pgsql-18/bin/postgresql-18-setup" # Zabbix paths. ZABBIX_SERVER_CONF = "/etc/zabbix/zabbix_server.conf" ZABBIX_SCHEMA = "/usr/share/zabbix/sql-scripts/postgresql/server.sql.gz" ZABBIX_TIMESCALE = "/usr/share/zabbix/sql-scripts/postgresql/timescaledb.sql" ZABBIX_NGINX_CONF = "/etc/nginx/conf.d/zabbix.conf" ZABBIX_PHP_CONF = "/etc/php-fpm.d/zabbix.conf" ZABBIX_CERT_DIR = "/etc/ssl/zabbix" ZABBIX_CERT_KEY = "/etc/ssl/zabbix/zabbix.key" ZABBIX_CERT_CRT = "/etc/ssl/zabbix/zabbix.crt" PHP_FPM_SOCK = "/run/php-fpm/zabbix.sock" # --------------------------------------------------------------------------- # Progress helpers — write to stderr so stdout stays clean # --------------------------------------------------------------------------- def _status(msg: str) -> None: """Print a progress message to stderr — stays on the same line until ``_status_done``.""" print(f" {msg}...", end="", flush=True, file=sys.stderr) def _status_done(msg: str = "done") -> None: """Complete the current progress line on stderr.""" print(f" {msg}", file=sys.stderr) def _info(msg: str) -> None: """Print an informational line to stderr.""" print(f" {DIM}{msg}{RESET}", file=sys.stderr) def _warn(msg: str) -> None: """Print a warning to stderr.""" print(f" {YELLOW}⚠ {msg}{RESET}", file=sys.stderr) def _ok(msg: str) -> None: """Print a success message to stderr.""" print(f" {GREEN}✓ {msg}{RESET}", file=sys.stderr) def _fail(msg: str) -> None: """Print a failure message to stderr.""" print(f" {RED}✗ {msg}{RESET}", file=sys.stderr) # --------------------------------------------------------------------------- # Low-level command runner # --------------------------------------------------------------------------- def run( cmd: list[str], timeout: int = 300, check: bool = False, env: dict[str, str] | None = None, input: str | None = None, ) -> subprocess.CompletedProcess[str]: """Execute a command and return the CompletedProcess. Sets LANG=C for reliable English-language output parsing. Raises subprocess.CalledProcessError when *check* is True and the command exits non-zero. *input* is fed to the command's stdin — use it for anything secret so it never appears in the process command line. """ merged_env = {**os.environ, "LANG": "C"} if env: merged_env.update(env) return subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, env=merged_env, input=input ) # --------------------------------------------------------------------------- # OS detection # --------------------------------------------------------------------------- def parse_os_release() -> dict[str, str]: """Parse /etc/os-release into a dict of KEY=VALUE pairs.""" result: dict[str, str] = {} try: with open("/etc/os-release", encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#"): continue if "=" not in line: continue key, _, value = line.partition("=") value = value.strip().strip('"').strip("'") result[key] = value except OSError: pass return result def detect_os() -> tuple[str, str, str]: """Detect the operating system. Returns a tuple of (os_id, display_name, version_id). Exits with an error message when the OS is not supported. """ release = parse_os_release() os_id = release.get("ID", "").lower() version_id = release.get("VERSION_ID", "unknown") if os_id not in SUPPORTED_OS: print( f"{RED}{BOLD}Error:{RESET} Unsupported operating system: " f"'{release.get('ID', 'unknown')}' (detected from /etc/os-release).", file=sys.stderr, ) print( f" Supported systems: {', '.join(SUPPORTED_OS.values())}", file=sys.stderr, ) sys.exit(1) return os_id, SUPPORTED_OS[os_id], version_id # --------------------------------------------------------------------------- # Root check # --------------------------------------------------------------------------- def check_root() -> None: """Exit with a clear message if not running as root (UID 0).""" if os.geteuid() != 0: print( f"{RED}{BOLD}Error:{RESET} This script must be run as root (UID 0).", file=sys.stderr, ) print( f" Current UID: {os.geteuid()}. Try: sudo python3 zabbix-deploy.py ...", file=sys.stderr, ) sys.exit(1) # --------------------------------------------------------------------------- # Idempotency helpers # --------------------------------------------------------------------------- def _file_exists(path: str) -> bool: """Return True if the path exists as a file.""" return os.path.isfile(path) def _dir_exists(path: str) -> bool: """Return True if the path exists as a directory.""" return os.path.isdir(path) def _rpm_installed(pkg: str) -> bool: """Return True if the RPM package is already installed.""" result = run(["rpm", "-q", pkg]) return result.returncode == 0 def _systemctl_is_active(service: str) -> bool: """Return True if the systemd service is active.""" result = run(["systemctl", "is-active", "--quiet", service]) return result.returncode == 0 def _systemctl_is_enabled(service: str) -> bool: """Return True if the systemd service is enabled.""" result = run(["systemctl", "is-enabled", "--quiet", service]) return result.returncode == 0 def _pg_user_exists(db_user: str) -> bool: """Return True if a PostgreSQL role already exists.""" result = run( [ "sudo", "-u", "postgres", f"{PG_BIN}/psql", "-tAc", f"SELECT 1 FROM pg_roles WHERE rolname='{db_user}'", ] ) return result.returncode == 0 and "1" in result.stdout def _pg_database_exists(db_name: str) -> bool: """Return True if a PostgreSQL database already exists.""" result = run( [ "sudo", "-u", "postgres", f"{PG_BIN}/psql", "-tAc", f"SELECT 1 FROM pg_database WHERE datname='{db_name}'", ] ) return result.returncode == 0 and "1" in result.stdout def _pg_tables_exist() -> bool: """Return True if the Zabbix schema is present (sentinel table dbversion).""" result = run( [ "sudo", "-u", "postgres", f"{PG_BIN}/psql", "zabbix", "-tAc", "SELECT 1 FROM information_schema.tables " "WHERE table_schema='public' AND table_name='dbversion'", ] ) return result.returncode == 0 and "1" in result.stdout def _pg_extension_exists(extension: str) -> bool: """Return True if a PostgreSQL extension is enabled in the zabbix DB.""" result = run( [ "sudo", "-u", "postgres", f"{PG_BIN}/psql", "zabbix", "-tAc", f"SELECT 1 FROM pg_extension WHERE extname='{extension}'", ] ) return result.returncode == 0 and "1" in result.stdout def _psql_stdin(sql: str, database: str | None = None) -> subprocess.CompletedProcess[str]: """Run SQL as the postgres OS user (peer auth) with SQL on stdin. Passing SQL via stdin keeps secrets out of the process command line (visible in `ps` and /proc) and out of the PostgreSQL log on error. """ cmd = ["sudo", "-u", "postgres", f"{PG_BIN}/psql", "-v", "ON_ERROR_STOP=1"] if database: cmd.append(database) return run(cmd, input=sql, timeout=60) def _read_existing_db_password() -> str | None: """Return the current DBPassword from zabbix_server.conf, or None.""" try: with open(ZABBIX_SERVER_CONF, encoding="utf-8") as fh: for line in fh: stripped = line.strip() if stripped.startswith("DBPassword="): value = stripped.split("=", 1)[1].strip() return value or None except OSError: pass return None def _firewalld_active() -> bool: """Return True if firewalld is running.""" return _systemctl_is_active("firewalld") def _firewall_rule_exists(rule: str, permanent: bool = True) -> bool: """Return True if a firewall rule already exists.""" args = ["firewall-cmd"] if permanent: args.append("--permanent") if rule.startswith("--add-port"): parts = rule.split("=") if len(parts) == 2: args.extend(["--query-port", parts[1]]) elif rule.startswith("--add-service"): parts = rule.split("=") if len(parts) == 2: args.extend(["--query-service", parts[1]]) else: return False result = run(args, timeout=10) return result.returncode == 0 def _selinux_get_bool(boolean_name: str) -> bool | None: """Return current state of an SELinux boolean, or None if unknown.""" result = run(["getsebool", boolean_name], timeout=10) if result.returncode != 0: return None # Output format: "boolean_name --> on" or "boolean_name --> off" match = re.search(rf"{boolean_name}\s*-->\s*(on|off)", result.stdout) if match: return match.group(1) == "on" return None def _map_to_rhel_version(os_id: str, version_id: str) -> tuple[str, str]: """Map an OS identity and version to the closest RHEL version pair. Returns a tuple of (el_major, rhel_version) for the Zabbix repo URL. Zabbix publishes RHEL repos only — Fedora and openEuler use the closest RHEL release (PGDG PostgreSQL has native Fedora repos, handled in setup_postgresql): Fedora (any) -> el9 openEuler 24+ -> el9 openEuler 22 -> el8 """ if os_id == "fedora": return ("9", "9") # openEuler try: major_ver = int(version_id.split(".")[0]) except (ValueError, IndexError): major_ver = 0 if major_ver >= 24: return ("9", "9") else: return ("8", "8") def _get_hostname() -> str: """Return the system hostname or 'localhost'.""" result = run(["hostname", "-f"], timeout=5) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip() result = run(["hostname"], timeout=5) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip() return "localhost" # --------------------------------------------------------------------------- # 1. Pre-flight checks # --------------------------------------------------------------------------- def preflight_checks(dry_run: bool = False) -> dict[str, Any]: """Run pre-flight checks: OS, root, memory. Returns a dict with check results. """ results: dict[str, Any] = {} # Root _status("Checking root privileges") check_root() _status_done("root") # OS _status("Detecting operating system") os_id, os_display, version_id = detect_os() _status_done(f"{os_display} {version_id}") results["os_id"] = os_id results["os_display"] = os_display results["version_id"] = version_id # Memory _status("Checking system memory") try: with open("/proc/meminfo", encoding="utf-8") as fh: for line in fh: if line.startswith("MemTotal:"): mem_kb = int(line.split(":")[1].strip().split()[0]) mem_gb = mem_kb / (1024 * 1024) results["memory_gb"] = round(mem_gb, 1) if mem_gb < 2.0: _status_done(f"{mem_gb:.1f} GB — LOW") _warn( "Less than 2 GB RAM detected. Zabbix with " "PostgreSQL may perform poorly." ) else: _status_done(f"{mem_gb:.1f} GB") break except OSError: _status_done("unknown") results["memory_gb"] = None return results # --------------------------------------------------------------------------- # 2. PostgreSQL 18 # --------------------------------------------------------------------------- def setup_postgresql( os_id: str, version_id: str, db_password: str, db_password_sync: bool = False, dry_run: bool = False, ) -> dict[str, Any]: """Install and configure PostgreSQL 18. Idempotent.""" results: dict[str, Any] = {} # Map to EL version for repo URLs (used for openEuler; Fedora has native # PGDG reporpms). el_major, _ = _map_to_rhel_version(os_id, version_id) # 2a. Add PostgreSQL official repo. PGDG publishes native Fedora reporpms # (F-) — prefer them over the EL repos on Fedora. _status("Adding PostgreSQL repository") if os_id == "fedora": pg_repo_pkg = "pgdg-fedora-repo" pg_repo_url = ( "https://download.postgresql.org/pub/repos/yum/reporpms/" f"F-{version_id}-x86_64/pgdg-fedora-repo-latest.noarch.rpm" ) else: pg_repo_pkg = "pgdg-redhat-repo" pg_repo_url = ( "https://download.postgresql.org/pub/repos/yum/reporpms/" f"EL-{el_major}-x86_64/pgdg-redhat-repo-latest.noarch.rpm" ) if _rpm_installed(pg_repo_pkg): _status_done("already present") elif dry_run: _status_done("dry run") _info(f" Would install: {pg_repo_url}") else: install_result = run(["dnf", "install", "-y", pg_repo_url], timeout=120) if install_result.returncode != 0: _status_done("failed") _fail(f"dnf: {install_result.stderr.strip()}") results["pg_repo"] = False return results _status_done("installed") results["pg_repo"] = True # 2b. Disable built-in PostgreSQL module (Fedora only). if os_id == "fedora": _status("Disabling built-in PostgreSQL module") mod_check = run(["dnf", "module", "list", "--enabled", "postgresql"], timeout=30) if "postgresql" in mod_check.stdout: if dry_run: _status_done("dry run") _info(" Would run: dnf module disable postgresql -y") else: disable_result = run(["dnf", "module", "disable", "postgresql", "-y"], timeout=120) if disable_result.returncode != 0: _status_done("failed (non-fatal)") _warn(f"Module disable: {disable_result.stderr.strip()}") else: _status_done("disabled") else: _status_done("not present") else: _status("PostgreSQL module disable") _status_done("skipped (openEuler)") # 2c. Install PostgreSQL 18 packages. pg_packages = ["postgresql18-server", "postgresql18"] for pkg in pg_packages: _status(f"Installing {pkg}") if _rpm_installed(pkg): _status_done("already present") elif dry_run: _status_done("dry run") else: inst = run(["dnf", "install", "-y", pkg], timeout=300) if inst.returncode != 0: _status_done("failed") _fail(f"dnf: {inst.stderr.strip()}") results[f"pkg_{pkg}"] = False return results _status_done("installed") results[f"pkg_{pkg}"] = True # 2d. Init DB (only if data directory empty). _status("Initialising PostgreSQL database") if _dir_exists(PG_DATA) and os.listdir(PG_DATA): _status_done("already initialised") elif dry_run: _status_done("dry run") _info(f" Would run: {PG_SETUP} initdb (UTF8 cluster)") else: # Guarantee a UTF8 cluster (Zabbix requires UTF8) regardless of the # locale this script runs under. init_result = run( [PG_SETUP, "initdb"], env={"PGSETUP_INITDB_OPTIONS": "--encoding=UTF8 --locale=C.UTF-8"}, timeout=120, ) if init_result.returncode != 0: _status_done("failed") _fail(f"initdb: {init_result.stderr.strip()}") results["pg_initdb"] = False return results _status_done("initialised") results["pg_initdb"] = True # 2e. Add scoped scram-sha-256 lines for the zabbix role's TCP connections. # The stock `local all all peer` line stays untouched — every psql call # in this script relies on peer auth as the postgres/zabbix OS users. The # scoped host lines guarantee password auth works for zabbix-server and # the frontend regardless of the stock host catch-all method. _status("Configuring pg_hba.conf (zabbix scram-sha-256)") scoped_lines = [ "host zabbix zabbix ::1/128 scram-sha-256", "host zabbix zabbix 127.0.0.1/32 scram-sha-256", ] if not _file_exists(PG_HBA): _status_done("not found") else: try: with open(PG_HBA, encoding="utf-8") as fh: hba_content = fh.read() missing = [line for line in scoped_lines if line not in hba_content] if not missing: _status_done("already configured") elif dry_run: _status_done("dry run") _info(" Would add scoped zabbix scram-sha-256 lines to pg_hba.conf") else: lines = hba_content.split("\n") # pg_hba is first-match-wins: insert before the first active # host line so the scoped rules take precedence over catch-alls. insert_at = next( ( i for i, line in enumerate(lines) if line.strip().startswith("host") and not line.strip().startswith("#") ), len(lines), ) lines[insert_at:insert_at] = [ "# zabbix-deploy: scoped auth for the zabbix role", *missing, ] with open(PG_HBA, "w", encoding="utf-8") as fh: fh.write("\n".join(lines)) _status_done("updated") # Reload so the change takes effect on an already-running server. if _systemctl_is_active(PG_SERVICE): run(["systemctl", "reload", PG_SERVICE], timeout=30) except OSError as exc: _status_done("failed") _fail(f"pg_hba.conf: {exc}") # 2f. Enable and start PostgreSQL. _status("Enabling and starting postgresql-18") if dry_run: _status_done("dry run") _info(" Would systemctl enable --now postgresql-18") else: if not _systemctl_is_enabled(PG_SERVICE): run(["systemctl", "enable", PG_SERVICE], timeout=30) if not _systemctl_is_active(PG_SERVICE): run(["systemctl", "start", PG_SERVICE], timeout=60) if _systemctl_is_active(PG_SERVICE): _status_done("running") else: _status_done("failed to start") results["pg_service"] = False return results results["pg_service"] = True # 2g. Create zabbix database user (password via stdin, never argv). escaped_pw = db_password.replace("'", "''") _status("Creating zabbix database user") if dry_run: _status_done("dry run") _info(" Would create user 'zabbix' with the provided password") elif _pg_user_exists("zabbix"): if db_password_sync: # Explicit password supplied — keep the role in sync with the # password written to zabbix_server.conf. alter = _psql_stdin(f"ALTER USER zabbix WITH PASSWORD '{escaped_pw}'") if alter.returncode != 0: _status_done("failed") _fail(f"psql: {alter.stderr.strip()}") results["pg_user"] = False return results _status_done("already exists (password synchronised)") else: _status_done("already exists") else: create_user = _psql_stdin(f"CREATE USER zabbix WITH PASSWORD '{escaped_pw}'") if create_user.returncode != 0: _status_done("failed") _fail(f"psql: {create_user.stderr.strip()}") results["pg_user"] = False return results _status_done("created") results["pg_user"] = True # 2h. Create zabbix database. _status("Creating zabbix database") if dry_run: _status_done("dry run") _info(" Would create database 'zabbix' owned by 'zabbix'") elif _pg_database_exists("zabbix"): _status_done("already exists") else: create_db = _psql_stdin("CREATE DATABASE zabbix OWNER zabbix") if create_db.returncode != 0: _status_done("failed") _fail(f"psql: {create_db.stderr.strip()}") results["pg_database"] = False return results _status_done("created") results["pg_database"] = True return results # --------------------------------------------------------------------------- # 3. Zabbix Repository # --------------------------------------------------------------------------- def setup_zabbix_repo( os_id: str, version_id: str, dry_run: bool = False, ) -> dict[str, Any]: """Add the Zabbix 7.4 official repository. Idempotent.""" results: dict[str, Any] = {} # Zabbix has no Fedora/openEuler-specific repo; the closest RHEL release # is used (assumption: el8/el9 packages work on the mapped targets). el_major, _ = _map_to_rhel_version(os_id, version_id) zabbix_repo_rpm = "zabbix-release" zabbix_repo_url = ( f"https://repo.zabbix.com/zabbix/7.4/release/rhel/" f"{el_major}/noarch/zabbix-release-latest.el{el_major}.noarch.rpm" ) # 3a. Exclude Zabbix from EPEL to avoid conflicts. epel_repo = "/etc/yum.repos.d/epel.repo" _status("Checking EPEL for zabbix exclusion") if _file_exists(epel_repo): try: with open(epel_repo, encoding="utf-8") as fh: epel_content = fh.read() if "exclude=zabbix*" in epel_content: _status_done("already excluded") elif dry_run: _status_done("dry run") _info(" Would add exclude=zabbix* to EPEL repo") else: # Add exclude line to the [epel] section. new_lines = [] for line in epel_content.split("\n"): new_lines.append(line) if line.strip().startswith("[epel]"): new_lines.append("exclude=zabbix*") with open(epel_repo, "w", encoding="utf-8") as fh: fh.write("\n".join(new_lines)) _status_done("exclusion added") except OSError as exc: _status_done("failed") _warn(f"EPEL exclusion: {exc}") else: _status_done("EPEL not present") # 3b. Install Zabbix repo RPM. _status("Installing Zabbix repository") if _rpm_installed(zabbix_repo_rpm): _status_done("already present") elif dry_run: _status_done("dry run") _info(f" Would run: rpm -Uvh {zabbix_repo_url}") else: inst = run(["rpm", "-Uvh", zabbix_repo_url], timeout=60) if inst.returncode != 0: _status_done("failed") _fail(f"rpm: {inst.stderr.strip()}") results["zabbix_repo"] = False return results _status_done("installed") results["zabbix_repo"] = True return results # --------------------------------------------------------------------------- # 4. Zabbix Packages # --------------------------------------------------------------------------- def setup_zabbix_packages(dry_run: bool = False) -> dict[str, Any]: """Install Zabbix server, frontend, and agent packages. Idempotent.""" results: dict[str, Any] = {} # php-fpm/php-cli/php-pgsql/nginx are listed explicitly — they are not # hard dependencies of zabbix-frontend-php (php-cli hashes the Admin # password, php-pgsql provides the frontend's PostgreSQL driver). packages = [ "zabbix-server-pgsql", "zabbix-frontend-php", "zabbix-nginx-conf", "zabbix-sql-scripts", "zabbix-selinux-policy", "zabbix-agent", "nginx", "php-fpm", "php-cli", "php-pgsql", ] for pkg in packages: _status(f"Installing {pkg}") if _rpm_installed(pkg): _status_done("already present") elif dry_run: _status_done("dry run") else: inst = run(["dnf", "install", "-y", pkg], timeout=300) if inst.returncode != 0: _status_done("failed") _fail(f"dnf: {inst.stderr.strip()}") results[f"pkg_{pkg}"] = False return results _status_done("installed") results[f"pkg_{pkg}"] = True return results # --------------------------------------------------------------------------- # 5. Database Schema # --------------------------------------------------------------------------- def setup_database_schema(dry_run: bool = False) -> dict[str, Any]: """Import Zabbix schema into PostgreSQL. Idempotent.""" results: dict[str, Any] = {} # 5a. Import base schema. Runs as the zabbix OS user via peer auth, so no # password is needed and the imported tables are owned by the zabbix # role — the official Zabbix documentation imports as the zabbix DB user # for exactly this ownership reason. _status("Importing Zabbix schema") if not _file_exists(ZABBIX_SCHEMA): _status_done("schema file not found") _warn(f"Missing: {ZABBIX_SCHEMA}") results["schema"] = False return results if _pg_tables_exist(): _status_done("already imported") results["schema"] = True return results if dry_run: _status_done("dry run") _info(f" Would run: zcat {ZABBIX_SCHEMA} | sudo -u zabbix psql -d zabbix") results["schema"] = True return results import_cmd = [ "bash", "-c", f"zcat {ZABBIX_SCHEMA} | sudo -u zabbix {PG_BIN}/psql -v ON_ERROR_STOP=1 -d zabbix", ] import_result = run(import_cmd, timeout=300) if import_result.returncode != 0: _status_done("failed") _fail(f"schema import: {import_result.stderr.strip()}") results["schema"] = False return results _status_done("imported") results["schema"] = True results["schema_fresh"] = True # 5b. Import TimescaleDB schema if available and not already applied # (idempotency marker: the extension row in pg_extension). _status("Checking TimescaleDB availability") ts_check = run( [ "sudo", "-u", "postgres", f"{PG_BIN}/psql", "zabbix", "-tAc", "SELECT 1 FROM pg_available_extensions WHERE name='timescaledb'", ], timeout=10, ) if ts_check.returncode != 0 or "1" not in ts_check.stdout: _status_done("not available — skipped") elif not _file_exists(ZABBIX_TIMESCALE): _status_done("script not found — skipped") elif _pg_extension_exists("timescaledb"): _status_done("already applied") results["timescaledb"] = True elif dry_run: _status_done("dry run") _info(f" Would import: {ZABBIX_TIMESCALE}") else: # CREATE EXTENSION needs the superuser; the script itself then runs # as the zabbix user (its own CREATE EXTENSION IF NOT EXISTS becomes # a no-op needing no privileges). ext = _psql_stdin( "CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE", database="zabbix", ) if ext.returncode != 0: _status_done("extension failed (non-fatal)") _warn(f"TimescaleDB extension: {ext.stderr.strip()}") else: ts_result = run( [ "bash", "-c", f"cat {ZABBIX_TIMESCALE} | sudo -u zabbix {PG_BIN}/psql -d zabbix", ], timeout=120, ) if ts_result.returncode != 0: _status_done("import failed (non-fatal)") _warn(f"TimescaleDB: {ts_result.stderr.strip()}") else: _status_done("imported") results["timescaledb"] = True return results # --------------------------------------------------------------------------- # 5b. Zabbix Admin Password # --------------------------------------------------------------------------- def set_admin_password( zabbix_password: str, dry_run: bool = False, ) -> dict[str, Any]: """Set the Zabbix Admin frontend password. The hash is produced by PHP's password_hash(PASSWORD_BCRYPT) — the same algorithm the Zabbix frontend uses for users.passwd since Zabbix 5.0 (ZBXNEXT-1898). Both the password and the SQL travel via stdin, never via the process command line. """ results: dict[str, Any] = {} _status("Setting Zabbix Admin password") if dry_run: _status_done("dry run") _info(" Would UPDATE users SET passwd= WHERE username='Admin'") return results if not _pg_tables_exist(): _status_done("schema not present — skipped") results["admin_password"] = False return results php = run( [ "php", "-r", "echo password_hash(trim(stream_get_contents(STDIN)), PASSWORD_BCRYPT);", ], input=zabbix_password, timeout=30, ) pw_hash = php.stdout.strip() if php.returncode != 0 or not pw_hash.startswith("$2"): _status_done("failed") _fail(f"php password_hash: {php.stderr.strip()}") results["admin_password"] = False return results # bcrypt hashes contain only [A-Za-z0-9./$] — safe in single-quoted SQL. update = _psql_stdin( f"UPDATE users SET passwd='{pw_hash}', attempt_failed=0 WHERE username='Admin'", database="zabbix", ) if update.returncode != 0: _status_done("failed") _fail(f"psql: {update.stderr.strip()}") results["admin_password"] = False return results _status_done("set") results["admin_password"] = True return results # --------------------------------------------------------------------------- # 6. Zabbix Server Config # --------------------------------------------------------------------------- def setup_zabbix_server( db_password: str, dry_run: bool = False, ) -> dict[str, Any]: """Configure and start Zabbix server. Idempotent.""" results: dict[str, Any] = {} # 6a. Configure zabbix_server.conf. _status("Configuring zabbix_server.conf") if not _file_exists(ZABBIX_SERVER_CONF): _status_done("not found") _warn(f"Missing: {ZABBIX_SERVER_CONF}") results["server_config"] = False return results config_changed = False if dry_run: _status_done("dry run") _info(" Would set DBPassword, DBHost=localhost, ListenPort=10051") else: try: with open(ZABBIX_SERVER_CONF, encoding="utf-8") as fh: config = fh.read() changed = False new_lines = [] for line in config.split("\n"): stripped = line.strip() if stripped.startswith("DBPassword=") and not stripped.startswith("#"): current_val = stripped.split("=", 1)[1] if "=" in stripped else "" if current_val != db_password: line = f"DBPassword={db_password}" changed = True elif stripped.startswith("# DBPassword="): line = f"DBPassword={db_password}" changed = True elif stripped.startswith("DBHost=") and not stripped.startswith("#"): if "localhost" not in stripped: line = "DBHost=localhost" changed = True elif stripped.startswith("# DBHost="): line = "DBHost=localhost" changed = True elif stripped.startswith("ListenPort=") and not stripped.startswith("#"): if "10051" not in stripped: line = "ListenPort=10051" changed = True elif stripped.startswith("# ListenPort="): line = "ListenPort=10051" changed = True new_lines.append(line) if changed: with open(ZABBIX_SERVER_CONF, "w", encoding="utf-8") as fh: fh.write("\n".join(new_lines)) _status_done("updated") config_changed = True else: _status_done("already configured") except OSError as exc: _status_done("failed") _fail(f"config: {exc}") results["server_config"] = False return results results["server_config"] = True # 6b. Enable and start zabbix-server. _status("Enabling and starting zabbix-server") if dry_run: _status_done("dry run") _info(" Would systemctl enable --now zabbix-server") else: if not _systemctl_is_enabled("zabbix-server"): run(["systemctl", "enable", "zabbix-server"], timeout=30) if not _systemctl_is_active("zabbix-server"): run(["systemctl", "start", "zabbix-server"], timeout=60) elif config_changed: # Config changed — restart so the new DB credentials take effect. run(["systemctl", "restart", "zabbix-server"], timeout=60) if _systemctl_is_active("zabbix-server"): _status_done("running") else: _status_done("failed to start") _warn("Check: journalctl -u zabbix-server") results["server_service"] = _systemctl_is_active("zabbix-server") # 6c. Enable and start zabbix-agent. _status("Enabling and starting zabbix-agent") if dry_run: _status_done("dry run") _info(" Would systemctl enable --now zabbix-agent") else: if not _systemctl_is_enabled("zabbix-agent"): run(["systemctl", "enable", "zabbix-agent"], timeout=30) if not _systemctl_is_active("zabbix-agent"): run(["systemctl", "start", "zabbix-agent"], timeout=60) if _systemctl_is_active("zabbix-agent"): _status_done("running") else: _status_done("failed to start") _warn("Check: journalctl -u zabbix-agent") results["agent_service"] = _systemctl_is_active("zabbix-agent") return results # --------------------------------------------------------------------------- # 8. nginx + PHP-FPM (TLS) # --------------------------------------------------------------------------- def _nginx_server_block() -> str: """Return the managed TLS nginx server block for the Zabbix frontend. Mirrors the stock zabbix-nginx-conf locations but listens on 443 with SSL (IPv6 first, IPv4 fallback) instead of the stock plain-HTTP :8080. """ template = r"""# Managed by zabbix-deploy.py — do not edit; changes will be overwritten. server { listen [::]:443 ssl; listen 443 ssl; server_name _; ssl_certificate @CERT@; ssl_certificate_key @KEY@; root /usr/share/zabbix; index index.php; location = /favicon.ico { log_not_found off; } location / { try_files $uri $uri/ =404; } location /assets { access_log off; expires 10d; } location ~ /\.ht { deny all; } location ~ /(api\/|conf[^\.]|include|locale) { deny all; return 404; } location /vendor { deny all; return 404; } location ~* [^/]\.php(/|$) { fastcgi_pass unix:@SOCK@; fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_index index.php; fastcgi_param DOCUMENT_ROOT /usr/share/zabbix; fastcgi_param SCRIPT_FILENAME /usr/share/zabbix$fastcgi_script_name; fastcgi_param PATH_TRANSLATED /usr/share/zabbix$fastcgi_script_name; include fastcgi_params; fastcgi_param QUERY_STRING $query_string; fastcgi_param REQUEST_METHOD $request_method; fastcgi_param CONTENT_TYPE $content_type; fastcgi_param CONTENT_LENGTH $content_length; fastcgi_intercept_errors on; fastcgi_ignore_client_abort off; fastcgi_connect_timeout 60; fastcgi_send_timeout 180; fastcgi_read_timeout 180; fastcgi_buffer_size 128k; fastcgi_buffers 4 256k; fastcgi_busy_buffers_size 256k; fastcgi_temp_file_write_size 256k; } } """ return ( template.replace("@CERT@", ZABBIX_CERT_CRT) .replace("@KEY@", ZABBIX_CERT_KEY) .replace("@SOCK@", PHP_FPM_SOCK) ) def setup_nginx_php( timezone: str, dry_run: bool = False, ) -> dict[str, Any]: """Configure nginx (TLS) and PHP-FPM for Zabbix frontend. Idempotent.""" results: dict[str, Any] = {} # 8a. Configure PHP-FPM timezone. _status("Configuring PHP-FPM for Zabbix") php_changed = False if not _file_exists(ZABBIX_PHP_CONF): _status_done("not found") _warn(f"Missing: {ZABBIX_PHP_CONF}") elif dry_run: _status_done("dry run") _info(f" Would set date.timezone={timezone}") else: try: with open(ZABBIX_PHP_CONF, encoding="utf-8") as fh: php_conf = fh.read() new_lines = [] for line in php_conf.split("\n"): if "php_value[date.timezone]" in line and not line.strip().startswith(";"): if timezone not in line: line = f"php_value[date.timezone] = {timezone}" php_changed = True elif line.strip().startswith(";php_value[date.timezone]"): line = f"php_value[date.timezone] = {timezone}" php_changed = True new_lines.append(line) if php_changed: with open(ZABBIX_PHP_CONF, "w", encoding="utf-8") as fh: fh.write("\n".join(new_lines)) _status_done("updated") else: _status_done("already configured") except OSError as exc: _status_done("failed") _fail(f"PHP-FPM config: {exc}") # 8b. Write the managed TLS server block (replacing the stock plain-HTTP # :8080 block) and validate it with nginx -t before it may take effect. _status("Configuring nginx for Zabbix (TLS :443)") nginx_changed = False nginx_config_ok = True if dry_run: _status_done("dry run") _info(f" Would write TLS server block to {ZABBIX_NGINX_CONF}") else: try: desired = _nginx_server_block() current = "" if _file_exists(ZABBIX_NGINX_CONF): with open(ZABBIX_NGINX_CONF, encoding="utf-8") as fh: current = fh.read() if current == desired: _status_done("already configured") else: with open(ZABBIX_NGINX_CONF, "w", encoding="utf-8") as fh: fh.write(desired) test = run(["nginx", "-t"], timeout=30) if test.returncode != 0: _status_done("config test failed") _fail(f"nginx -t: {test.stderr.strip()}") # Roll back so nginx is not left with a broken config. if current: with open(ZABBIX_NGINX_CONF, "w", encoding="utf-8") as fh: fh.write(current) nginx_config_ok = False else: _status_done("written (TLS :443)") nginx_changed = True except OSError as exc: _status_done("failed") _fail(f"nginx config: {exc}") nginx_config_ok = False results["nginx_config"] = nginx_config_ok # 8c. Enable and start php-fpm (reload when the pool config changed). _status("Enabling and starting php-fpm") if dry_run: _status_done("dry run") _info(" Would systemctl enable --now php-fpm") else: if not _systemctl_is_enabled("php-fpm"): run(["systemctl", "enable", "php-fpm"], timeout=30) if not _systemctl_is_active("php-fpm"): run(["systemctl", "start", "php-fpm"], timeout=60) elif php_changed: run(["systemctl", "reload", "php-fpm"], timeout=30) if _systemctl_is_active("php-fpm"): _status_done("running") else: _status_done("failed to start") _warn("Check: journalctl -u php-fpm") # 8d. Enable and start nginx (reload only after a successful nginx -t). _status("Enabling and starting nginx") if dry_run: _status_done("dry run") _info(" Would systemctl enable --now nginx") elif not nginx_config_ok: _status_done("skipped (config test failed)") else: if not _systemctl_is_enabled("nginx"): run(["systemctl", "enable", "nginx"], timeout=30) if not _systemctl_is_active("nginx"): run(["systemctl", "start", "nginx"], timeout=60) elif nginx_changed: run(["systemctl", "reload", "nginx"], timeout=30) if _systemctl_is_active("nginx"): _status_done("running") else: _status_done("failed to start") _warn("Check: journalctl -u nginx") results["nginx"] = _systemctl_is_active("nginx") results["php_fpm"] = _systemctl_is_active("php-fpm") return results # --------------------------------------------------------------------------- # 7. TLS Certificate # --------------------------------------------------------------------------- def setup_tls(dry_run: bool = False) -> dict[str, Any]: """Create self-signed TLS certificate for nginx. Idempotent.""" results: dict[str, Any] = {} _status("Setting up TLS certificate") if _file_exists(ZABBIX_CERT_KEY) and _file_exists(ZABBIX_CERT_CRT): _status_done("already present") results["cert"] = "exists" return results if dry_run: _status_done("dry run") _info(f" Would create self-signed cert in {ZABBIX_CERT_DIR}") return results hostname = _get_hostname() # Restrictive umask so the private key is never world-readable, not even # in the window between openssl writing it and the chmod below. old_umask = os.umask(0o077) try: if not _dir_exists(ZABBIX_CERT_DIR): os.makedirs(ZABBIX_CERT_DIR, mode=0o700, exist_ok=True) cert_result = run( [ "openssl", "req", "-x509", "-nodes", "-days", "365", "-newkey", "rsa:2048", "-keyout", ZABBIX_CERT_KEY, "-out", ZABBIX_CERT_CRT, "-subj", f"/CN={hostname}", "-addext", f"subjectAltName=DNS:{hostname}", ], timeout=60, ) finally: os.umask(old_umask) if cert_result.returncode != 0: _status_done("failed") _fail(f"openssl: {cert_result.stderr.strip()}") results["cert"] = False return results # Set proper permissions. os.chmod(ZABBIX_CERT_KEY, 0o600) os.chmod(ZABBIX_CERT_CRT, 0o644) _status_done("created") results["cert"] = "created" return results # --------------------------------------------------------------------------- # 9. SELinux # --------------------------------------------------------------------------- def setup_selinux(dry_run: bool = False) -> dict[str, Any]: """Configure SELinux booleans for Zabbix. Idempotent.""" results: dict[str, Any] = {} # Check if SELinux is enforcing. selinux_mode = "" try: with open("/sys/fs/selinux/enforce", encoding="utf-8") as fh: selinux_mode = fh.read().strip() except OSError: pass if selinux_mode != "1": _status("SELinux") _status_done("not enforcing — skipped") return results booleans = { "httpd_can_network_connect_db": "Allow HTTPd to connect to PostgreSQL", "zabbix_can_network": "Allow Zabbix to use network", } for boolean, description in booleans.items(): _status(f"SELinux: {description}") current = _selinux_get_bool(boolean) if current is None: _status_done("boolean not found — skipped") elif current is True: _status_done("already on") results[boolean] = True elif dry_run: _status_done("dry run") _info(f" Would run: setsebool -P {boolean} on") else: set_result = run(["setsebool", "-P", boolean, "on"], timeout=10) if set_result.returncode != 0: _status_done("failed") _warn(f"setsebool {boolean}: {set_result.stderr.strip()}") results[boolean] = False else: _status_done("enabled") results[boolean] = True return results # --------------------------------------------------------------------------- # 10. Firewall # --------------------------------------------------------------------------- def setup_firewall( skip_firewall: bool = False, dry_run: bool = False, ) -> dict[str, Any]: """Open firewall ports for Zabbix. Idempotent.""" results: dict[str, Any] = {} if skip_firewall: _status("Firewall") _status_done("skipped by --skip-firewall") return results if not _firewalld_active(): _status("Firewall") _status_done("firewalld not running — skipped") results["firewall_active"] = False return results results["firewall_active"] = True # Every component (server, agent, database) runs on localhost — only # the HTTPS frontend needs to be reachable. The server trapper port # 10051 deliberately stays closed. rules = [ ("--add-service=https", "service https (frontend :443)"), ] for rule, label in rules: _status(f"Firewall: {label}") if _firewall_rule_exists(rule, permanent=True): _status_done("already allowed") elif dry_run: _status_done("dry run") _info(f" Would run: firewall-cmd {rule} --permanent") else: parts = rule.split("=") if len(parts) != 2: _status_done("invalid rule") continue fw_cmd = ["firewall-cmd"] if rule.startswith("--add-port"): fw_cmd.extend(["--add-port", parts[1]]) elif rule.startswith("--add-service"): fw_cmd.extend(["--add-service", parts[1]]) fw_cmd.append("--permanent") fw_result = run(fw_cmd, timeout=30) if fw_result.returncode != 0: _status_done("failed") _warn(f"firewall-cmd: {fw_result.stderr.strip()}") else: _status_done("added") results[label] = True # Reload firewall rules. _status("Reloading firewall") if dry_run: _status_done("dry run") else: reload_result = run(["firewall-cmd", "--reload"], timeout=30) if reload_result.returncode == 0: _status_done("reloaded") else: _status_done("reload failed") _warn(f"firewall-cmd reload: {reload_result.stderr.strip()}") results["firewall_configured"] = True return results # --------------------------------------------------------------------------- # 12. Uninstall # --------------------------------------------------------------------------- def uninstall(dry_run: bool = False) -> dict[str, Any]: """Tear down the Zabbix deployment. Idempotent. Package-level removal only. PostgreSQL itself, the zabbix database and role, firewall rules, SELinux booleans and repository configuration are deliberately left untouched — the exact revert commands are listed at the end. """ results: dict[str, Any] = {} services = ["zabbix-server", "zabbix-agent", "php-fpm", "nginx", PG_SERVICE] # Stop and disable all services. for svc in services: _status(f"Stopping {svc}") if _systemctl_is_active(svc) or _systemctl_is_enabled(svc): if dry_run: _status_done("dry run") else: run(["systemctl", "stop", svc], timeout=60) run(["systemctl", "disable", svc], timeout=30) _status_done("stopped and disabled") else: _status_done("not running") # Remove Zabbix packages. zabbix_packages = [ "zabbix-server-pgsql", "zabbix-frontend-php", "zabbix-nginx-conf", "zabbix-sql-scripts", "zabbix-selinux-policy", "zabbix-agent", ] installed = [p for p in zabbix_packages if _rpm_installed(p)] if installed: _status(f"Removing Zabbix packages ({len(installed)})") if dry_run: _status_done("dry run") else: rm_result = run(["dnf", "remove", "-y"] + installed, timeout=300) if rm_result.returncode != 0: _status_done("failed") _warn(f"dnf remove: {rm_result.stderr.strip()}") else: _status_done("removed") results["packages_removed"] = rm_result.returncode == 0 else: _status("Removing Zabbix packages") _status_done("nothing to remove") # Remove Zabbix repo. _status("Removing Zabbix repository") if _rpm_installed("zabbix-release"): if dry_run: _status_done("dry run") else: run(["rpm", "-e", "zabbix-release"], timeout=30) _status_done("removed") else: _status_done("not present") # Remove TLS certificates (created by this script, not RPM-owned). _status("Removing TLS certificates") if _dir_exists(ZABBIX_CERT_DIR): if dry_run: _status_done("dry run") else: for fname in ("zabbix.key", "zabbix.crt"): fpath = os.path.join(ZABBIX_CERT_DIR, fname) if _file_exists(fpath): os.remove(fpath) try: os.rmdir(ZABBIX_CERT_DIR) except OSError: pass _status_done("removed") else: _status_done("not present") # The nginx/PHP-FPM Zabbix configs are RPM-owned — `dnf remove` handles # them (locally modified files are preserved as .rpmsave; see below). # What deliberately persists, with the exact revert commands. leftovers = [ ( "zabbix database and role", 'sudo -u postgres psql -c "DROP DATABASE zabbix" -c "DROP USER zabbix"', ), ("PostgreSQL 18 data directory", "rm -rf /var/lib/pgsql/18/data"), ( "pg_hba.conf zabbix scram lines", "edit /var/lib/pgsql/18/data/pg_hba.conf (remove the marked lines)", ), ( "SELinux booleans", "setsebool -P httpd_can_network_connect_db off && setsebool -P zabbix_can_network off", ), ( "firewall https rule", "firewall-cmd --permanent --remove-service=https && firewall-cmd --reload", ), ("PGDG repository", "dnf remove pgdg-redhat-repo pgdg-fedora-repo"), ( "EPEL zabbix exclusion", "edit /etc/yum.repos.d/epel.repo (remove exclude=zabbix*)", ), ( "modified config backups", "rm -f /etc/nginx/conf.d/zabbix.conf.rpmsave /etc/php-fpm.d/zabbix.conf.rpmsave", ), ] print( f"\n {BOLD}Persists after uninstall (revert manually if desired):{RESET}", file=sys.stderr, ) for label, command in leftovers: print(f" • {label}: {DIM}{command}{RESET}", file=sys.stderr) return results # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- def print_summary( zabbix_password: str, db_password: str, os_display: str, version_id: str, results: dict[str, Any], warnings: list[str], elapsed: float, dry_run: bool = False, uninstall_mode: bool = False, ) -> None: """Print a final summary of all sections to stderr.""" mode = "Uninstall" if uninstall_mode else ("Dry Run" if dry_run else "Summary") print(f"\n{BOLD}── {mode} ──{RESET}", file=sys.stderr) if uninstall_mode: ur = results.get("uninstall", {}) for key, label in [ ("packages_removed", "Zabbix packages removed"), ]: if ur.get(key): _ok(label) elif dry_run: _info("Would install and configure:") for label in ( "PostgreSQL 18 (UTF8 cluster, zabbix role + database)", "Zabbix 7.4 repository and packages", "Database schema import (as zabbix user, peer auth)", "Zabbix Admin password (bcrypt via PHP password_hash)", "Zabbix server + agent", "TLS certificate and nginx HTTPS frontend on :443", "PHP-FPM, SELinux booleans, firewall (https)", ): _info(f" • {label}") else: # Postgres pg = results.get("postgresql", {}) if pg.get("pg_service") and pg.get("pg_user") and pg.get("pg_database"): _ok("PostgreSQL 18: running") elif not pg: pass # Not attempted. else: _fail("PostgreSQL 18: failed") # Zabbix repo zr = results.get("zabbix_repo", {}) if zr.get("zabbix_repo"): _ok("Zabbix 7.4 repository: configured") # Zabbix packages zp = results.get("zabbix_packages", {}) failed_pkgs = [k for k, v in zp.items() if v is False] if failed_pkgs: _fail(f"Zabbix packages failed: {', '.join(failed_pkgs)}") elif zp: _ok("Zabbix packages: installed") # Schema schema = results.get("database_schema", {}) if schema.get("schema") is True: _ok("Database schema: imported") elif schema.get("schema") is False: _fail("Database schema: failed") # Admin password admin = results.get("admin_password", {}) if admin.get("admin_password") is True: _ok("Admin password: set") elif admin.get("admin_password") is False: _fail("Admin password: failed") # Server server = results.get("zabbix_server", {}) if server.get("server_service"): _ok("Zabbix server: running") if server.get("agent_service"): _ok("Zabbix agent: running") # nginx + PHP-FPM nginx_php = results.get("nginx_php", {}) if nginx_php.get("nginx"): _ok("nginx: running") if nginx_php.get("php_fpm"): _ok("PHP-FPM: running") # TLS tls = results.get("tls", {}) cert_state = tls.get("cert", "") if cert_state in ("exists", "created"): _ok("TLS certificate: configured") elif cert_state is False: _fail("TLS certificate: failed") # SELinux selinux = results.get("selinux", {}) selinux_failed = [k for k, v in selinux.items() if v is False] if selinux_failed: _fail(f"SELinux booleans failed: {', '.join(selinux_failed)}") elif any(v is True for v in selinux.values()): _ok("SELinux: configured") # Firewall fw = results.get("firewall", {}) if fw.get("firewall_active") is False: _info("Firewall: firewalld not running — skipped") elif fw.get("firewall_configured"): _ok("Firewall: configured") # Credentials — only reached on the successful deploy path; abort # paths pass an empty db_password so nothing is shown for a role # that failed to be created. if db_password: hostname = _get_hostname() print(file=sys.stderr) print(f" {BOLD}Frontend:{RESET} https://{hostname}/", file=sys.stderr) print(f" {BOLD}Username:{RESET} Admin", file=sys.stderr) if zabbix_password: print(f" {BOLD}Password:{RESET} {zabbix_password}", file=sys.stderr) else: print( f" {BOLD}Password:{RESET} (unchanged — existing deployment)", file=sys.stderr, ) print(f" {BOLD}DB Password:{RESET} {db_password}", file=sys.stderr) print( f" {DIM}Store these credentials securely — they are shown only now.{RESET}", file=sys.stderr, ) print(file=sys.stderr) print( f" {YELLOW}Note:{RESET} Complete the web-based setup wizard on first access.", file=sys.stderr, ) if warnings: print(file=sys.stderr) for w in warnings: _warn(w) print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr) print(f" {BOLD}Total time: {elapsed:.1f}s{RESET}", file=sys.stderr) print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr) print(file=sys.stderr) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def parse_args(argv: list[str] | None = None) -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser( description="Idempotent Zabbix 7.4 deployment with PostgreSQL 18, nginx, PHP-FPM", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--db-password", type=str, default=None, dest="db_password", help="PostgreSQL zabbix user password (default: generated, or reused from " "zabbix_server.conf on re-runs; prefer the ZABBIX_DB_PASSWORD env var)", ) parser.add_argument( "--timezone", type=str, default="Europe/Prague", help="PHP timezone (default: Europe/Prague)", ) parser.add_argument( "--zabbix-password", type=str, default=None, dest="zabbix_password", help="Zabbix admin password (default: generated; prefer the " "ZABBIX_ADMIN_PASSWORD env var — flags leak into shell history)", ) parser.add_argument( "--dry-run", action="store_true", help="Preview without making changes", ) parser.add_argument( "--uninstall", action="store_true", help="Tear down Zabbix deployment", ) parser.add_argument( "--skip-firewall", action="store_true", dest="skip_firewall", help="Skip firewall rules", ) parser.add_argument( "--version", action="version", version=f"%(prog)s {VERSION}", ) return parser.parse_args(argv) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> None: """Entry point — deploy or uninstall Zabbix.""" start_time = time.monotonic() warnings: list[str] = [] results: dict[str, Any] = {} args = parse_args() check_root() os_id, os_display, version_id = detect_os() # Resolve passwords. CLI flags work but leak into shell history and # `ps` output — prefer the environment variables. On re-runs without a # supplied DB password, the existing one is reused from # zabbix_server.conf so the database and the server config stay in sync. db_password_explicit = bool(args.db_password or os.environ.get("ZABBIX_DB_PASSWORD")) if args.db_password or args.zabbix_password: warnings.append( "Passwords passed as CLI flags are visible in shell history and `ps`" " — prefer ZABBIX_DB_PASSWORD / ZABBIX_ADMIN_PASSWORD env vars" ) db_password = ( args.db_password or os.environ.get("ZABBIX_DB_PASSWORD") or _read_existing_db_password() or secrets.token_urlsafe(16) ) zabbix_password_explicit = bool(args.zabbix_password or os.environ.get("ZABBIX_ADMIN_PASSWORD")) zabbix_password = ( args.zabbix_password or os.environ.get("ZABBIX_ADMIN_PASSWORD") or secrets.token_urlsafe(16) ) # Header. print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr) print( f"{BOLD} Zabbix Deploy v{VERSION} — {os_display} {version_id}{RESET}", file=sys.stderr, ) print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr) if args.dry_run: print( f"\n {YELLOW}{BOLD}DRY RUN — no changes will be made{RESET}", file=sys.stderr, ) if args.uninstall and not args.dry_run: print( f"\n {YELLOW}{BOLD}UNINSTALL MODE — all Zabbix components will be removed{RESET}", file=sys.stderr, ) print(file=sys.stderr) # ── Uninstall path ── if args.uninstall: print(f"\n{BOLD}── Uninstall ──{RESET}", file=sys.stderr) results["uninstall"] = uninstall(dry_run=args.dry_run) elapsed = time.monotonic() - start_time print_summary( zabbix_password="", db_password="", os_display=os_display, version_id=version_id, results=results, warnings=warnings, elapsed=elapsed, dry_run=args.dry_run, uninstall_mode=True, ) return # ── Deploy path ── # 1. Pre-flight checks. print(f"\n{BOLD}── Pre-flight Checks ──{RESET}", file=sys.stderr) preflight = preflight_checks(dry_run=args.dry_run) results["preflight"] = preflight mem = preflight.get("memory_gb") if mem is not None and mem < 2.0: warnings.append( f"Low memory detected ({mem:.1f} GB). Zabbix + PostgreSQL recommend at least 2 GB RAM." ) # 2. PostgreSQL 18. print(f"\n{BOLD}── PostgreSQL 18 ──{RESET}", file=sys.stderr) results["postgresql"] = setup_postgresql( os_id=os_id, version_id=version_id, db_password=db_password, db_password_sync=db_password_explicit, dry_run=args.dry_run, ) pg = results["postgresql"] if not pg.get("pg_service") or not pg.get("pg_user") or not pg.get("pg_database"): warnings.append("PostgreSQL provisioning failed — aborting deployment") elapsed = time.monotonic() - start_time print_summary( zabbix_password="", db_password="", os_display=os_display, version_id=version_id, results=results, warnings=warnings, elapsed=elapsed, dry_run=args.dry_run, ) sys.exit(1) # 3. Zabbix Repository. print(f"\n{BOLD}── Zabbix Repository ──{RESET}", file=sys.stderr) results["zabbix_repo"] = setup_zabbix_repo( os_id=os_id, version_id=version_id, dry_run=args.dry_run, ) # 4. Zabbix Packages. print(f"\n{BOLD}── Zabbix Packages ──{RESET}", file=sys.stderr) results["zabbix_packages"] = setup_zabbix_packages(dry_run=args.dry_run) # 5. Database Schema. print(f"\n{BOLD}── Database Schema ──{RESET}", file=sys.stderr) results["database_schema"] = setup_database_schema(dry_run=args.dry_run) schema = results["database_schema"] if schema.get("schema") is not True: warnings.append("Database schema import failed — aborting deployment") elapsed = time.monotonic() - start_time print_summary( zabbix_password="", db_password="", os_display=os_display, version_id=version_id, results=results, warnings=warnings, elapsed=elapsed, dry_run=args.dry_run, ) sys.exit(1) # 5b. Set the Admin frontend password for real (bcrypt) when the schema # was just imported or a password was explicitly supplied. On plain # re-runs it is left untouched and reported as unchanged. if args.dry_run or schema.get("schema_fresh") or zabbix_password_explicit: results["admin_password"] = set_admin_password( zabbix_password=zabbix_password, dry_run=args.dry_run, ) if results["admin_password"].get("admin_password") is False: warnings.append( "Admin password could not be set — on a fresh install the" " default Admin/zabbix credentials are still active" ) # 6. Zabbix Server Config. print(f"\n{BOLD}── Zabbix Server ──{RESET}", file=sys.stderr) results["zabbix_server"] = setup_zabbix_server( db_password=db_password, dry_run=args.dry_run, ) server = results["zabbix_server"] if server.get("server_service") is False: warnings.append("Zabbix server failed to start") if server.get("agent_service") is False: warnings.append("Zabbix agent failed to start") # 7. TLS Certificate (before nginx — the server block references the cert). print(f"\n{BOLD}── TLS Certificate ──{RESET}", file=sys.stderr) results["tls"] = setup_tls(dry_run=args.dry_run) if results["tls"].get("cert") is False: warnings.append("TLS certificate creation failed — HTTPS frontend will not work") # 8. nginx + PHP-FPM. print(f"\n{BOLD}── nginx + PHP-FPM ──{RESET}", file=sys.stderr) results["nginx_php"] = setup_nginx_php( timezone=args.timezone, dry_run=args.dry_run, ) # 9. SELinux. print(f"\n{BOLD}── SELinux ──{RESET}", file=sys.stderr) results["selinux"] = setup_selinux(dry_run=args.dry_run) for boolean, boolean_ok in results["selinux"].items(): if boolean_ok is False: warnings.append(f"SELinux boolean failed: {boolean}") # 10. Firewall. print(f"\n{BOLD}── Firewall ──{RESET}", file=sys.stderr) results["firewall"] = setup_firewall( skip_firewall=args.skip_firewall, dry_run=args.dry_run, ) # Only show the Admin password when it was actually set this run. admin_password_shown = ( zabbix_password if results.get("admin_password", {}).get("admin_password") is True else "" ) elapsed = time.monotonic() - start_time print_summary( zabbix_password=admin_password_shown, db_password=db_password, os_display=os_display, version_id=version_id, results=results, warnings=warnings, elapsed=elapsed, dry_run=args.dry_run, ) if __name__ == "__main__": main()