Files
scripts/zabbix-deploy.py

2015 lines
69 KiB
Python

#!/usr/bin/env python3
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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,
CentOS Stream 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.1.0"
SUPPORTED_OS: dict[str, str] = {
"fedora": "Fedora",
"centos": "CentOS Stream",
"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 == "centos":
return (version_id, version_id)
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 non-Fedora; 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-<version>) — 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 (non-Fedora)")
# 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=<bcrypt> 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()