2015 lines
69 KiB
Python
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()
|