commit 64e5b325144890f6363d70b4fd4f6e53c4722c09 Author: Petr Balvín Date: Tue Jul 21 22:42:45 2026 +0200 chore: initial commit of sysadmin scripts collection diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..532b08e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ + +# Ruff +.ruff_cache/ + +# Pytest +.pytest_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f83dd2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1ddec8 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Scripts — DevOps Toolbox + +A personal collection of Python scripts for deploying, monitoring, and +maintaining Linux systems. Every script is **idempotent** (safe to run +repeatedly), requires **no external dependencies** (stdlib only), and +ships with `--dry-run` where applicable. + +--- + +## Stack + +| Concern | Choice | +|---------|--------| +| Language | Python 3.12+ | +| Package manager | None — stdlib only | +| Target platforms | Fedora Server, openEuler; FreeBSD 13+ (`network-diag.py` only) | +| Lint / format | Ruff (line length 100 — see `ruff.toml`) | +| Licence | MIT | + +--- + +## Scripts + +### Diagnostics + +| Script | Version | Purpose | +|--------|---------|---------| +| `network-diag.py` | 2.1.1 | Network latency, DNS, MTU, packet loss, dual-stack — Linux + FreeBSD | +| `system-diag.py` | 1.0.0 | Full system health: CPU, memory, disk, GPU, services, security, performance — with A–F grade. Linux only | + +### Deployment + +| Script | Version | Purpose | +|--------|---------|---------| +| `server-setup.py` | 1.2.0 | Server initial setup: packages, firewall (SSH verified before start), SELinux, Podman, automatic updates | +| `workstation-setup.py` | 4.1.0 | Fedora desktop setup: Brave, dev tools (Bun, Zed, uv, Rust), Flatpak apps, SSH + Git | +| `vllm-deploy.py` | 1.1.0 | vLLM inference server behind nginx with HTTPS and API-key auth — installs ROCm on AMD GPUs | +| `zabbix-deploy.py` | 1.0.0 | Zabbix 7.4 monitoring stack: PostgreSQL 18, nginx with HTTPS on :443, PHP-FPM, SELinux | + +### Maintenance + +| Script | Version | Purpose | +|--------|---------|---------| +| `system-optimise.py` | 1.3.1 | System cleanup: old kernels (running + one fallback always kept), journals, temp files, core dumps, package audit. Refuses rpm-ostree systems | + +--- + +## Quick Start + +```bash +git clone https://codeberg.org/petrbalvin/scripts.git && cd scripts +``` + +All scripts are standalone — run them directly: + +```bash +# Check your network (Linux or FreeBSD) +python3 network-diag.py + +# Full system health report (Linux) +python3 system-diag.py + +# Set up a new server +sudo python3 server-setup.py --dry-run + +# Set up a development workstation +sudo python3 workstation-setup.py --dry-run + +# Deploy vLLM (interactive model selection) +sudo python3 vllm-deploy.py + +# Deploy the Zabbix monitoring stack +sudo python3 zabbix-deploy.py --dry-run + +# Clean up old kernels and temp files +sudo python3 system-optimise.py --dry-run +``` + +Every script supports `--help` and `--version` without root privileges. +Use `--dry-run` to preview changes before applying them, and `--skip-*` +flags to skip individual sections. + +--- + +## Notes + +### Security + +- **`vllm-deploy.py`** always protects the HTTPS endpoint with an API key. + Supply your own with `--api-key`, or let the script generate one — it is + stored in a root-only `EnvironmentFile` and printed once in the summary. +- **`zabbix-deploy.py`** accepts secrets via the environment variables + `ZABBIX_DB_PASSWORD` and `ZABBIX_ADMIN_PASSWORD` (preferred), or the + `--db-password` / `--zabbix-password` flags (visible in shell history). + The database password is never passed on any command line. +- **`workstation-setup.py`** verifies what it downloads: Bun is installed + from a pinned release with SHA-256 verification, uv comes from the + Fedora repositories, and Brave's GPG key is checked against known + fingerprints before import. Zed's installer is pinned but published + without checksums — the script says so loudly. +- **`system-diag.py`** only contacts a third-party service + (`icanhazip.com`) when you pass `--external-ip`. + +### Platform support + +- `network-diag.py` runs on Linux and FreeBSD. On FreeBSD older than 13 + it falls back to the legacy `ping6` binary where available. +- `system-diag.py` is Linux-only (it reads `/proc` and `/sys`) and says + so clearly on other platforms. +- The deployment and maintenance scripts target Fedora and openEuler + (dnf4 and dnf5 are both handled). + +--- + +## Development + +No build step, no dependencies. Lint and format with Ruff +(configuration in `ruff.toml`, line length 100): + +```bash +ruff check . +ruff format --check . +``` + +--- + +## Licence + +MIT — see [`LICENSE`](LICENSE). +Copyright © 2026 [Petr Balvín](https://petrbalvin.org) diff --git a/network-diag.py b/network-diag.py new file mode 100755 index 0000000..2a1ca0f --- /dev/null +++ b/network-diag.py @@ -0,0 +1,1051 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +# SPDX-License-Identifier: MIT + +"""Network diagnostics — latency, DNS, MTU, packet loss, dual-stack, and port checks. + +No external dependencies — leverages system tools (ping, ip, ss, ifconfig, +netstat, sockstat) with fallback to pure stdlib where possible. + +Supports Linux and FreeBSD (13+ recommended; on pre-13 FreeBSD the legacy +ping6 binary is used for IPv6 instead of the unified ``ping -6``). + +Usage: + network-diag.py # full diagnostic + network-diag.py --target cloudflare.com # target a specific host + network-diag.py --protocol v4 # IPv4 only + network-diag.py --protocol v6 # IPv6 only +""" + +from __future__ import annotations + +import argparse +import ipaddress +import os +import re +import shutil +import socket +import statistics +import subprocess +import sys +import time +from concurrent.futures import Future, ThreadPoolExecutor, as_completed + +BOLD = "\033[1m" +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +DIM = "\033[2m" +RESET = "\033[0m" +VERSION = "2.1.1" + +# Well-known hosts for connectivity tests +CONNECTIVITY_HOSTS = [ + ("cloudflare.com", 443), + ("google.com", 443), +] + +# Ping statistics differ between iputils (Linux) and FreeBSD: +# Linux: "5 packets transmitted, 5 received, 0% packet loss, time 4003ms" +# FreeBSD: "5 packets transmitted, 5 packets received, 0.0% packet loss" +_PING_TIME_RE = re.compile(r"time=(\d+(?:\.\d+)?)") +_PING_RECEIVED_RE = re.compile(r"(\d+)\s+(?:packets?\s+)?received") + + +def detect_os() -> str: + """Return 'linux' or 'freebsd' based on uname.""" + system = os.uname().sysname.lower() + if system == "freebsd": + return "freebsd" + return "linux" # default + + +def _status(msg: str) -> None: + """Print progress message to stderr — stays on 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 run(cmd: list[str], timeout: int = 15) -> tuple[str, str] | None: + """Execute a command and return (stdout, stderr), or None if not installed. + + Sets LC_ALL=C (and LANG=C) for reliable English-language output parsing + and decodes with ``errors="replace"`` so unexpected bytes never raise. + Returns ("", "") on timeout and None only when the binary is missing, so + callers can distinguish "tool unavailable" from "no output". + """ + try: + env = {**os.environ, "LANG": "C", "LC_ALL": "C"} + result = subprocess.run( + cmd, + capture_output=True, + text=True, + errors="replace", + timeout=timeout, + env=env, + ) + return result.stdout, result.stderr + except subprocess.TimeoutExpired: + return "", "" + except FileNotFoundError: + return None + + +def _parse_ping_output(output: str) -> tuple[list[float], int | None]: + """Parse ping output for per-reply latencies and the received counter. + + Returns (latencies in ms, received count). The received count is None + when no statistics line was recognised; callers should then fall back + to len(latencies). + """ + latencies = [float(m.group(1)) for m in _PING_TIME_RE.finditer(output)] + received: int | None = None + for line in output.split("\n"): + if "transmitted" in line: + match = _PING_RECEIVED_RE.search(line) + if match: + received = int(match.group(1)) + break + return latencies, received + + +def _ping_result_to_dict( + host: str, + latencies: list[float], + received: int, + count: int, + protocol: str, +) -> dict[str, object]: + """Build a standardised ping result dictionary from parsed data. + + ``loss_pct`` is derived from the authoritative received counter, not + from the number of parsed ``time=`` lines. + """ + loss_pct = round((count - received) / count * 100, 1) if count > 0 else 100.0 + result: dict[str, object] = { + "host": host, + "protocol": protocol, + "sent": count, + "received": received, + "loss_pct": loss_pct, + "min_ms": 0.0, + "avg_ms": 0.0, + "max_ms": 0.0, + "stddev_ms": 0.0, + } + if latencies: + result["min_ms"] = round(min(latencies), 1) + result["avg_ms"] = round(statistics.mean(latencies), 1) + result["max_ms"] = round(max(latencies), 1) + if len(latencies) > 1: + result["stddev_ms"] = round(statistics.stdev(latencies), 1) + return result + + +def _freebsd_major() -> int: + """Parse the FreeBSD major version from uname release (99 if unsure).""" + try: + return int(os.uname().release.split("-")[0].split(".")[0]) + except (ValueError, IndexError): + return 99 # assume a modern, unified ping when unparseable + + +def _ping_base_cmd(os_type: str, family: str) -> list[str] | None: + """Return the ping command prefix for *family*, or None if unavailable. + + FreeBSD 13+ ships a unified ping understanding -4/-6; earlier releases + need the legacy ping6 binary for IPv6. + """ + if os_type == "freebsd" and _freebsd_major() < 13: + if family == "v6": + return ["ping6"] if shutil.which("ping6") else None + return ["ping"] + return ["ping", "-6" if family == "v6" else "-4"] + + +def _ping_wait_args(os_type: str, base_cmd: list[str], timeout: int) -> list[str]: + """Per-reply wait flag for the given ping binary. + + Linux iputils ``-W`` takes seconds; FreeBSD ping ``-W`` takes + milliseconds; legacy FreeBSD ping6 uses ``-x`` (milliseconds) instead. + """ + if os_type == "freebsd": + flag = "-x" if base_cmd[0] == "ping6" else "-W" + return [flag, str(timeout * 1000)] + return ["-W", str(timeout)] + + +def ping_host( + host: str, + os_type: str, + count: int = 5, + timeout: int = 5, + protocol: str = "v4", +) -> dict[str, object]: + """Ping a host over one address family (``"v4"`` or ``"v6"``). + + Uses the OS-appropriate binary and flags (see ``_ping_base_cmd``). + When no suitable ping binary exists, the result carries a ``"note"`` + key explaining the gap instead of silently reporting zero replies. + """ + base = _ping_base_cmd(os_type, protocol) + if base is None: + result = _ping_result_to_dict(host, [], 0, count, protocol) + result["note"] = "no ping binary with IPv6 support found" + return result + cmd = [*base, "-c", str(count), *_ping_wait_args(os_type, base, timeout), "--", host] + # The subprocess timeout must also cover ping's own DNS resolution time. + res = run(cmd, timeout=count + timeout + 10) + if res is None: + result = _ping_result_to_dict(host, [], 0, count, protocol) + result["note"] = f"'{base[0]}' not found" + return result + latencies, parsed_received = _parse_ping_output(res[0]) + received = parsed_received if parsed_received is not None else len(latencies) + return _ping_result_to_dict(host, latencies, received, count, protocol) + + +def check_interface_info(os_type: str = "linux") -> dict[str, object]: + """Collect network interface addresses and default routes.""" + info: dict[str, object] = { + "interfaces": [], + "default_route_v4": "", + "default_route_v6": "", + "error": "", + } + if os_type == "freebsd": + return _check_interface_info_freebsd(info) + return _check_interface_info_linux(info) + + +def _check_interface_info_linux(info: dict[str, object]) -> dict[str, object]: + """Collect interface info on Linux using iproute2.""" + res = run(["ip", "-br", "addr", "show"]) + if res is None: + info["error"] = "'ip' not found" + return info + for line in res[0].split("\n"): + parts = line.split() + if len(parts) >= 2 and parts[0] != "lo": + info["interfaces"].append( + { + "name": parts[0], + "state": parts[1], + "ips": parts[2:], + } + ) + + route4 = run(["ip", "-4", "route", "show", "default"]) + if route4 and route4[0].strip(): + parts = route4[0].strip().split() + for i, p in enumerate(parts): + if p == "via" and i + 1 < len(parts): + info["default_route_v4"] = parts[i + 1] + break + + route6 = run(["ip", "-6", "route", "show", "default"]) + if route6 and route6[0].strip(): + parts = route6[0].strip().split() + for i, p in enumerate(parts): + if p == "via" and i + 1 < len(parts): + info["default_route_v6"] = parts[i + 1] + break + return info + + +def _check_interface_info_freebsd(info: dict[str, object]) -> dict[str, object]: + """Collect interface info on FreeBSD using ifconfig and netstat.""" + iflist = run(["ifconfig", "-l"]) + if iflist is None: + info["error"] = "'ifconfig' not found" + return info + ifaces = iflist[0].strip().split() + if not ifaces: + return info + + for ifname in ifaces: + if ifname == "lo0": + continue + iface = run(["ifconfig", ifname]) + if iface is None: + continue + lines = iface[0].split("\n") + if not lines or not lines[0]: + continue + + # Parse flags line: name: flags=... + state = "DOWN" + if "" in lines[0]: + state = "UP" + + ips: list[str] = [] + for line in lines[1:]: + stripped = line.strip() + if stripped.startswith("inet "): + # inet 192.168.1.100 netmask ... + addr = stripped.split()[1] + if addr != "127.0.0.1": + ips.append(addr) + elif stripped.startswith("inet6 "): + addr = stripped.split()[1].split("%")[0] # strip %scope suffix + # Skip link-local (fe80:) and loopback (::1) + if addr.startswith("fe80:") or addr == "::1": + continue + ips.append(addr) + + info["interfaces"].append({"name": ifname, "state": state, "ips": ips}) + + # Default routes via netstat + route = run(["netstat", "-rn"]) + if route is None: + return info + for line in route[0].split("\n"): + if not line.startswith("default"): + continue + parts = line.split() + if len(parts) < 2: + continue + gateway = parts[1].split("%")[0] # strip %scope from link-local gateways + # Determine v4 vs v6 by checking for colons + if ":" in gateway: + if not info["default_route_v6"]: + info["default_route_v6"] = gateway + elif not info["default_route_v4"]: + info["default_route_v4"] = gateway + + return info + + +def check_connectivity() -> dict[str, object]: + """TCP handshake probes to well-known hosts over both address families. + + This is the single place performing TCP probes; the per-family + reachability sections reuse these results (see ``_reachability_report``). + """ + info: dict[str, object] = {"v4": {}, "v6": {}} + for host, port in CONNECTIVITY_HOSTS: + info["v6"][host] = _tcp_probe(host, port, socket.AF_INET6) # IPv6 first + info["v4"][host] = _tcp_probe(host, port, socket.AF_INET) + return info + + +def _tcp_probe(host: str, port: int, family: int) -> dict[str, object]: + """Attempt a TCP connection and return timing info. + + Iterates all resolved candidates (in getaddrinfo order) and passes the + full sockaddr tuple to connect(), preserving the IPv6 scope id. + """ + try: + addrs = socket.getaddrinfo(host, port, family=family, type=socket.SOCK_STREAM) + except OSError: + return {"reachable": False} + for af, socktype, proto, _canonname, sockaddr in addrs: + try: + with socket.socket(af, socktype, proto) as sock: + sock.settimeout(5) + start = time.perf_counter() + sock.connect(sockaddr) + elapsed = round((time.perf_counter() - start) * 1000, 1) + return {"reachable": True, "ip": str(sockaddr[0]), "ms": elapsed} + except OSError: + continue + return {"reachable": False} + + +def check_dns(protocol: str = "auto") -> dict[str, object]: + """Test DNS resolution latency for multiple domains. + + Tests both A (IPv4) and AAAA (IPv6) resolution when ``protocol`` is + ``"auto"``. Respects ``--protocol v4`` / ``--protocol v6`` to restrict + to a single address family. + """ + info: dict[str, object] = { + "hostname": socket.gethostname(), + "fqdn": "", + "resolvers": [], + "v4": {}, + "v6": {}, + } + + try: + info["fqdn"] = socket.getfqdn() + except OSError: + pass + + domains = [host for host, _port in CONNECTIVITY_HOSTS] + + # IPv6 first + if protocol in ("auto", "v6"): + for domain in domains: + info["v6"][domain] = _dns_resolve(domain, socket.AF_INET6) + if protocol in ("auto", "v4"): + for domain in domains: + info["v4"][domain] = _dns_resolve(domain, socket.AF_INET) + + try: + with open("/etc/resolv.conf") as f: + for line in f: + parts = line.split() + if line.startswith("nameserver") and len(parts) >= 2: + info["resolvers"].append(parts[1]) + except OSError: + pass + + return info + + +def _dns_resolve(domain: str, family: int) -> list[dict[str, object]]: + """Resolve *domain* three times and return latency samples.""" + results: list[dict[str, object]] = [] + for _ in range(3): + try: + start = time.perf_counter() + addr = socket.getaddrinfo(domain, None, family=family) + elapsed = (time.perf_counter() - start) * 1000 + results.append( + { + "ip": addr[0][4][0] if addr else "?", + "ms": round(elapsed, 1), + } + ) + except OSError: + results.append({"ip": "N/A", "ms": -1}) + return results + + +def _family_addresses( + iface_info: dict[str, object], family: str +) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]: + """Usable addresses of *family* from collected interface data. + + Excludes loopback, link-local, unspecified and multicast addresses. + Globally-scoped addresses (per the IANA registries in ``ipaddress``) + come first, private/ULA ones after. + """ + version = 6 if family == "v6" else 4 + global_addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = [] + other_addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = [] + interfaces = iface_info.get("interfaces") if isinstance(iface_info, dict) else None + for ifc in interfaces or []: + for raw in ifc.get("ips", []): + try: + ip = ipaddress.ip_interface(raw).ip + except ValueError: + continue + if ip.version != version: + continue + if ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast: + continue + (global_addrs if ip.is_global else other_addrs).append(ip) + return global_addrs + other_addrs + + +def _reachability_report( + family: str, iface_info: dict[str, object], probes: dict[str, object] +) -> dict[str, object]: + """Build a per-family reachability section from interface data + probes. + + ``addr`` is the best address found on any interface (global scope + preferred); ``is_global`` distinguishes it from private/ULA space. + """ + addresses = _family_addresses(iface_info, family) + best = addresses[0] if addresses else None + return { + "available": best is not None, + "addr": str(best) if best is not None else "", + "is_global": bool(best is not None and best.is_global), + "test_sites": probes, + } + + +def _resolve_probe_target(target: str, protocol: str) -> tuple[str | None, str | None]: + """Resolve *target* to (family, numeric address), preferring IPv6. + + Returns (None, None) when no address of the requested family exists. + Resolving here (instead of letting ping decide) makes the address + family deterministic so MTU header arithmetic stays correct. + """ + families = {"v4": socket.AF_INET, "v6": socket.AF_INET6} + order = ["v6", "v4"] if protocol == "auto" else [protocol] + for family in order: + try: + infos = socket.getaddrinfo(target, None, family=families[family]) + except OSError: + continue + if infos: + return family, str(infos[0][4][0]) + return None, None + + +def check_mtu( + target: str = "cloudflare.com", + protocol: str = "auto", + os_type: str = "linux", +) -> dict[str, object]: + """Path MTU discovery using system ping with fragmentation disabled. + + Success is defined POSITIVELY (a parsed reply); failure patterns are + matched in BOTH stdout and stderr, because iputils prints the local + "message too long" error to stderr while the PING banner goes to + stdout. Header sizes are per family: 28 bytes for IPv4 (20 IP + 8 + ICMP), 48 for IPv6 (40 IPv6 + 8 ICMPv6). On Linux uses ``ping -M + do``; on FreeBSD ``ping -D``. + """ + info: dict[str, object] = {"path_mtu": 0, "target": target, "method": ""} + + family, addr = _resolve_probe_target(target, protocol) + if family is None or addr is None: + info["method"] = "target did not resolve" + return info + header = 48 if family == "v6" else 28 + + base = _ping_base_cmd(os_type, family) + if base is None: + info["method"] = "no ping binary with IPv6 support found" + return info + df_args = ["-D"] if os_type == "freebsd" else ["-M", "do"] + wait_args = _ping_wait_args(os_type, base, 2) + + for size in (1500, 1400, 1300, 1280, 1200, 1000): + payload = size - header + cmd = [*base, *df_args, "-c", "1", *wait_args, "-s", str(payload), "--", addr] + res = run(cmd, timeout=12) # generous: covers the wait plus extra slack + if res is None: + info["method"] = f"'{base[0]}' not found" + return info + out, err = res + received = _parse_ping_output(out)[1] + if received and received > 0: + info["path_mtu"] = size + info["method"] = f"ping DF probe ({size}B OK)" + break + combined = (out + err).lower() + if "frag needed" in combined or "message too long" in combined: + continue # definitively too big — try a smaller size + # timeout, black-holed ICMP errors, or unreachable — try smaller anyway + else: + info["method"] = "ping DF probe (no probe size succeeded)" + + return info + + +def _parse_ss_output(stdout: str) -> list[dict[str, str]]: + """Parse ``ss -tlnp`` output into a list of {addr, process, bucket} dicts.""" + entries: list[dict[str, str]] = [] + for line in stdout.split("\n")[1:]: + if "LISTEN" not in line: + continue + parts = line.split() + if len(parts) < 5: + continue + # ss -tlnp columns: State Recv-Q Send-Q LocalAddress:Port PeerAddress:Port Process + # 0 1 2 3 4 5+ + local = parts[3] + # IPv6 addresses in ss output use bracket notation: [::]:port + bucket = "tcp6" if local.startswith("[") else "tcp4" + process = parts[-1] if len(parts) > 5 else "" + entries.append({"addr": local, "process": process, "bucket": bucket}) + return entries + + +def check_listening_ports(os_type: str = "linux") -> dict[str, object]: + """Show locally listening TCP ports, split by address family. + + On Linux, uses ``ss -tlnp`` for the port listing. IPv6 entries are + detected by the bracket notation (``[::]:port``) in the local address + column. When process info is missing (ports owned by other users), + re-runs the command via ``sudo -n`` for non-interactive privilege + escalation. Entries that remain unresolvable are shown as + ``(no permission)``. + + On FreeBSD, uses ``sockstat -l4 -l6``; dual-stack sockets (``tcp46``) + are reported in their own bucket. + """ + if os_type == "freebsd": + return _check_listening_ports_freebsd() + return _check_listening_ports_linux() + + +def _check_listening_ports_linux() -> dict[str, object]: + """Listening ports on Linux via ss.""" + info: dict[str, object] = {"tcp4": [], "tcp6": [], "tcp46": [], "error": ""} + res = run(["ss", "-tlnp"], timeout=5) + if res is None: + info["error"] = "'ss' not found" + return info + entries = _parse_ss_output(res[0]) + + # If any process fields came back empty, try sudo for the full picture. + if any(e["process"] == "" for e in entries): + sudo_res = run(["sudo", "-n", "ss", "-tlnp"], timeout=5) + if sudo_res and sudo_res[0]: + sudo_entries = _parse_ss_output(sudo_res[0]) + # Merge: overwrite entries where the sudo output gave us a process name. + sudo_map = {e["addr"]: e for e in sudo_entries} + for e in entries: + if e["process"] == "" and e["addr"] in sudo_map: + e["process"] = sudo_map[e["addr"]]["process"] + + for entry in entries: + process = entry["process"] if entry["process"] else "(no permission)" + info[entry["bucket"]].append({"addr": entry["addr"], "process": process}) + + return info + + +def _check_listening_ports_freebsd() -> dict[str, object]: + """Listening ports on FreeBSD via sockstat (tcp46 = dual-stack).""" + info: dict[str, object] = {"tcp4": [], "tcp6": [], "tcp46": [], "error": ""} + res = run(["sockstat", "-l4", "-l6"], timeout=5) + if res is None: + info["error"] = "'sockstat' not found" + return info + for line in res[0].split("\n")[1:]: # skip header + parts = line.split() + if len(parts) < 6: + continue + proto = parts[4] # tcp4, tcp6, or tcp46 (dual-stack) + if proto not in ("tcp4", "tcp6", "tcp46"): + continue + info[proto].append({"addr": parts[5], "process": parts[1]}) + return info + + +def compute_grade(data: dict[str, object]) -> dict[str, object]: + """Compute an A–F summary grade based on key metrics. + + Scoring factors (each 0–20 points, total 100): + - Packet loss (lower is better) + - Latency (lower is better) + - DNS resolution speed (lower is better) + - IPv4 reachability (presence of address + test-site reachability) + - IPv6 / dual-stack support (presence of address + test-site reachability) + + Letter mapping: A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F < 60. + """ + score = 0 + breakdown: dict[str, int] = {} + + # --- Packet loss (20 pts) --- + ping = data.get("ping", {}) + loss = float(ping.get("loss_pct", 100)) + if loss <= 0: + loss_score = 20 + elif loss <= 5: + loss_score = 15 + elif loss <= 10: + loss_score = 10 + elif loss <= 30: + loss_score = 5 + else: + loss_score = 0 + breakdown["packet_loss"] = loss_score + score += loss_score + + # --- Latency (20 pts) --- + avg_ms = float(ping.get("avg_ms", 0)) + if avg_ms <= 0: + latency_score = 0 + elif avg_ms < 30: + latency_score = 20 + elif avg_ms < 80: + latency_score = 15 + elif avg_ms < 150: + latency_score = 10 + elif avg_ms < 300: + latency_score = 5 + else: + latency_score = 0 + breakdown["latency"] = latency_score + score += latency_score + + # --- DNS speed (20 pts) --- + dns = data.get("dns", {}) + dns_samples = 0 + dns_total = 0.0 + for family in ("v6", "v4"): + for domain_results in dns.get(family, {}).values(): + for r in domain_results: + if r["ms"] > 0: + dns_samples += 1 + dns_total += r["ms"] + dns_score = 0 + if dns_samples > 0: + dns_avg = dns_total / dns_samples + if dns_avg < 20: + dns_score = 20 + elif dns_avg < 50: + dns_score = 15 + elif dns_avg < 100: + dns_score = 10 + elif dns_avg < 200: + dns_score = 5 + breakdown["dns_speed"] = dns_score + score += dns_score + + # --- IPv4 reachability (20 pts) --- + ipv4 = data.get("ipv4", {}) + v4_score = 0 + if ipv4.get("available"): + v4_score += 10 + # bonus for each reachable test site + sites = ipv4.get("test_sites", {}) + reachable_count = sum(1 for s in sites.values() if s.get("reachable")) + v4_score += min(reachable_count * 5, 10) + breakdown["ipv4_reachability"] = v4_score + score += v4_score + + # --- IPv6 / dual-stack (20 pts) --- + ipv6 = data.get("ipv6", {}) + v6_score = 0 + if ipv6.get("available"): + v6_score += 10 + sites = ipv6.get("test_sites", {}) + reachable_count = sum(1 for s in sites.values() if s.get("reachable")) + v6_score += min(reachable_count * 5, 10) + breakdown["ipv6_dualstack"] = v6_score + score += v6_score + + # Letter mapping + if score >= 90: + letter = "A" + elif score >= 80: + letter = "B" + elif score >= 70: + letter = "C" + elif score >= 60: + letter = "D" + else: + letter = "F" + + grade_color = GREEN if letter in ("A", "B") else YELLOW if letter in ("C", "D") else RED + + return { + "grade": letter, + "score": score, + "max_score": 100, + "colour": grade_color, + "breakdown": breakdown, + } + + +def _ms_colour(ms: float) -> str: + """Return an ANSI colour code for a given latency value in ms.""" + if ms <= 0: + return DIM + if ms < 50: + return GREEN + if ms < 200: + return YELLOW + return RED + + +def _print_reachability(family: str, info: dict[str, object] | None) -> None: + """Pretty-print one per-family reachability section.""" + if not info: + return + label = "IPv6" if family == "v6" else "IPv4" + if not info.get("available"): + print(f" {YELLOW}○{RESET} No {label} address detected on any interface") + return + scope = "Global" if info.get("is_global") else "Private" + print(f" {GREEN}✓{RESET} {scope} {label} address: {info['addr']}") + suffix = " over IPv6" if family == "v6" else "" + for site, result in info.get("test_sites", {}).items(): + if result.get("reachable"): + print(f" {GREEN}✓{RESET} {site:<20s} {result['ms']}ms ({result['ip']})") + else: + print(f" {RED}✗{RESET} {site:<20s} unreachable{suffix}") + + +def print_report(data: dict[str, object]) -> None: + """Render the human-readable diagnostic report to stdout.""" + grade_info = data.get("grade", {}) + grade_letter = str(grade_info.get("grade", "?")) + grade_colour = str(grade_info.get("colour", RESET)) + + # --- Header --- + os_label = str(data.get("os", "Linux")).capitalize() + print(f"\n{BOLD}{'═' * 60}{RESET}") + print(f"{BOLD} Network Diagnostics v{VERSION} — {os_label}{RESET}") + print(f"{BOLD}{'═' * 60}{RESET}") + print(f" Hostname: {data['hostname']}") + print(f" Protocol: {data.get('protocol', 'auto')}") + score = grade_info.get("score", "?") + max_score = grade_info.get("max_score", 100) + print(f" Summary Grade: {grade_colour}{BOLD}{grade_letter}{RESET} ({score}/{max_score})") + + # --- Interfaces --- + print(f"\n{BOLD}── Interfaces ──{RESET}") + iface = data.get("interfaces", {}) + if iface.get("error"): + print(f" {DIM}unavailable: {iface['error']}{RESET}") + for ifc in iface.get("interfaces", []): + state_color = GREEN if ifc["state"] == "UP" else RED + ips = ", ".join(ifc["ips"]) + print(f" {ifc['name']:<8s} {state_color}{ifc['state']:<5s}{RESET} {ips}") + if iface.get("default_route_v6"): + print(f" Default IPv6 route → {iface['default_route_v6']}") + if iface.get("default_route_v4"): + print(f" Default IPv4 route → {iface['default_route_v4']}") + + # --- Internet connectivity --- + print(f"\n{BOLD}── Internet Connectivity ──{RESET}") + conn = data.get("connectivity", {}) + if conn.get("error"): + print(f" {DIM}unavailable: {conn['error']}{RESET}") + elif conn: + for family_label, fam_key in [("IPv6", "v6"), ("IPv4", "v4")]: + fam_data = conn.get(fam_key, {}) + status_parts = [] + for host in fam_data: + result = fam_data[host] + if result.get("reachable"): + ms = result.get("ms", 0) + colour = _ms_colour(ms) + status_parts.append(f"{GREEN}✓{RESET} {host} {colour}{ms:.0f}ms{RESET}") + else: + status_parts.append(f"{RED}✗{RESET} {host}") + hosts_line = ", ".join(status_parts) if status_parts else f"{DIM}(none){RESET}" + print(f" {family_label}: {hosts_line}") + + # --- Latency (ICMP ping) --- + print(f"\n{BOLD}── Latency (ICMP ping) ──{RESET}") + _print_ping_block("IPv6", data.get("ping_v6")) + _print_ping_block("IPv4", data.get("ping_v4")) + + # --- DNS --- + print(f"\n{BOLD}── DNS Resolution ──{RESET}") + dns = data.get("dns", {}) + if dns: + print(f" FQDN: {dns.get('fqdn', '?')}") + if dns.get("resolvers"): + print(f" Resolvers: {', '.join(dns['resolvers'])}") + for family_label, fam_key in [("AAAA (IPv6)", "v6"), ("A (IPv4)", "v4")]: + domains = dns.get(fam_key, {}) + if not domains: + continue + print(f" {BOLD}{family_label}:{RESET}") + for domain, results in domains.items(): + valid = [r["ms"] for r in results if r["ms"] > 0] + avg = statistics.mean(valid) if valid else 0 + colour = _ms_colour(avg) + ips = [r["ip"] for r in results if r["ip"] != "N/A"] + ip_str = ips[0] if ips else "N/A" + print(f" {domain:<25s} → {colour}{ip_str:<18s}{RESET} {avg:.0f}ms") + + # --- IPv6 reachability --- + print(f"\n{BOLD}── IPv6 Reachability ──{RESET}") + _print_reachability("v6", data.get("ipv6")) + + # --- IPv4 reachability --- + print(f"\n{BOLD}── IPv4 Reachability ──{RESET}") + _print_reachability("v4", data.get("ipv4")) + + # --- Path MTU --- + print(f"\n{BOLD}── Path MTU ──{RESET}") + mtu = data.get("mtu", {}) + if mtu: + if mtu.get("path_mtu"): + colour = YELLOW if mtu["path_mtu"] < 1500 else GREEN + print(f" {colour}{mtu['path_mtu']}B{RESET} ({mtu.get('method', '')})") + elif mtu.get("method"): + print(f" {DIM}{mtu['method']}{RESET}") + elif mtu.get("error"): + print(f" {DIM}unavailable: {mtu['error']}{RESET}") + + # --- Listening ports --- + print(f"\n{BOLD}── Listening Ports ──{RESET}") + ports = data.get("listening", {}) + if ports: + if ports.get("error"): + print(f" {DIM}unavailable: {ports['error']}{RESET}") + else: + total = sum(len(v) for v in ports.values() if isinstance(v, list)) + if total > 0: + for version in ["tcp6", "tcp4", "tcp46"]: + for entry in ports.get(version, []): + print(f" {version} {entry['addr']:<22s} {DIM}{entry['process']}{RESET}") + else: + print(" (none)") + + # --- Grade breakdown --- + print(f"\n{BOLD}── Grade Breakdown ──{RESET}") + bd = grade_info.get("breakdown", {}) + labels = { + "packet_loss": "Packet loss", + "latency": "Latency", + "dns_speed": "DNS speed", + "ipv6_dualstack": "IPv6 / dual-stack", + "ipv4_reachability": "IPv4 reachability", + } + for key, label in labels.items(): + pts = bd.get(key, 0) + bar = "█" * (pts // 2) + "░" * (10 - pts // 2) + print(f" {label:<20s} {bar} {pts}/20") + + # --- Footer --- + print(f"\n{BOLD}{'═' * 60}{RESET}") + print( + f" {BOLD}Overall Grade: {grade_colour}{grade_letter}{RESET} " + f"({grade_info.get('score', '?')}/{grade_info.get('max_score', 100)})" + ) + print(f"{BOLD}{'═' * 60}{RESET}\n") + + +def _print_ping_block(label: str, ping_info: dict[str, object] | None) -> None: + """Pretty-print a single ping result block for a given protocol.""" + if not isinstance(ping_info, dict): + return + if ping_info.get("received", 0) == 0: + note = ping_info.get("note") or ping_info.get("error") + suffix = f" ({note})" if note else "" + print(f" {label}: {RED}no response{suffix}{RESET}") + return + + loss = float(ping_info.get("loss_pct", 0)) + loss_color = RED if loss > 10 else YELLOW if loss > 0 else GREEN + avg = float(ping_info.get("avg_ms", 0)) + latency_color = _ms_colour(avg) + print( + f" {label}: {ping_info.get('sent')}/{ping_info.get('received')} received" + f" Loss: {loss_color}{loss}%{RESET}" + f" Avg: {latency_color}{avg:.1f}ms{RESET}" + f" Min/Max: {ping_info.get('min_ms', 0):.1f}/{ping_info.get('max_ms', 0):.1f}ms" + f" σ: {ping_info.get('stddev_ms', 0):.1f}ms" + ) + + +def main() -> None: + """Parse arguments, collect diagnostics, and print the report.""" + parser = argparse.ArgumentParser( + description="Network diagnostics — latency, DNS, MTU, dual-stack, ports", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--target", + default="cloudflare.com", + help="Target host for tests (default: cloudflare.com)", + ) + parser.add_argument( + "--protocol", + choices=["auto", "v4", "v6"], + default="auto", + help="Address family: auto (dual-stack), v4 (IPv4 only), v6 (IPv6 only)", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION}", + ) + args = parser.parse_args() + + target: str = args.target + protocol: str = args.protocol + + os_type = detect_os() + + data: dict[str, object] = { + "hostname": socket.gethostname(), + "version": VERSION, + "os": os_type, + "protocol": protocol, + "interfaces": {}, + "connectivity": {}, + "ping": {}, + "ping_v4": None, + "ping_v6": None, + "dns": {}, + "ipv4": {}, + "ipv6": {}, + "mtu": {}, + "listening": {}, + "grade": {}, + } + + # Phase 1: interface info (instant, sequential) + _status("Checking interfaces") + data["interfaces"] = check_interface_info(os_type) + iface_error = data["interfaces"].get("error") + _status_done(f"warning: {iface_error}" if iface_error else "done") + + # Phase 2: all other checks run in parallel + with ThreadPoolExecutor(max_workers=6) as executor: + future_map: dict[Future[object], tuple[str, str]] = {} + + # Connectivity (TCP handshakes; results shared with reachability) + fut: Future[object] = executor.submit(check_connectivity) + future_map[fut] = ("connectivity", "internet connectivity") + + # Ping(s) — honour protocol preference, IPv6 first + if protocol in ("auto", "v6"): + fut = executor.submit(ping_host, target, os_type, protocol="v6") + future_map[fut] = ("ping_v6", "ping (IPv6)") + if protocol in ("auto", "v4"): + fut = executor.submit(ping_host, target, os_type, protocol="v4") + future_map[fut] = ("ping_v4", "ping (IPv4)") + + fut = executor.submit(check_dns, protocol) + future_map[fut] = ("dns", "DNS resolution") + fut = executor.submit(check_mtu, target, protocol, os_type) + future_map[fut] = ("mtu", "path MTU") + fut = executor.submit(check_listening_ports, os_type) + future_map[fut] = ("listening", "listening ports") + + # Collect results as they complete, showing progress on stderr + print(f" Running {len(future_map)} checks in parallel...", file=sys.stderr) + for future in as_completed(future_map): + key, label = future_map[future] + try: + data[key] = future.result() + except Exception as exc: + data[key] = {"error": str(exc)} + print(f" {label}... failed ({exc})", file=sys.stderr) + continue + note = data[key].get("note") if isinstance(data[key], dict) else None + if note: + print(f" {label}... warning ({note})", file=sys.stderr) + else: + print(f" {label}... done", file=sys.stderr) + + # Derive per-family reachability from interface data + shared probes + conn = data.get("connectivity") + probes_v4 = conn.get("v4", {}) if isinstance(conn, dict) else {} + probes_v6 = conn.get("v6", {}) if isinstance(conn, dict) else {} + data["ipv6"] = _reachability_report("v6", data["interfaces"], probes_v6) + data["ipv4"] = _reachability_report("v4", data["interfaces"], probes_v4) + + # Post-process ping results: pick the best for the dual-stack summary + if protocol == "v4": + data["ping"] = data.get("ping_v4", {}) + elif protocol == "v6": + data["ping"] = data.get("ping_v6", {}) + else: + # Dual-stack: prefer IPv6 when it answered (IPv6-first) + ping_v4 = data.get("ping_v4") + ping_v6 = data.get("ping_v6") + v6_recv = int(ping_v6.get("received", 0)) if isinstance(ping_v6, dict) else 0 + if v6_recv > 0 and isinstance(ping_v6, dict): + data["ping"] = ping_v6 + elif isinstance(ping_v4, dict): + data["ping"] = ping_v4 + else: + data["ping"] = {} + + # Compute summary grade + data["grade"] = compute_grade(data) + + print_report(data) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nInterrupted.", file=sys.stderr) + sys.exit(130) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..71859d8 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,9 @@ +# Ruff configuration for the sysadmin scripts collection. +# Docs: https://docs.astral.sh/ruff/configuration/ +# Default rule selection is kept intentionally — stricter linting is a separate decision. + +# Owner style: 100-character lines (applies to both the linter and the formatter). +line-length = 100 + +# Scripts target Python 3.12+ (stdlib only). +target-version = "py312" diff --git a/server-setup.py b/server-setup.py new file mode 100644 index 0000000..4abf672 --- /dev/null +++ b/server-setup.py @@ -0,0 +1,1361 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +# SPDX-License-Identifier: MIT + +"""Idempotent server setup for Fedora Server and openEuler. + +No external dependencies — stdlib and system tools only. Every operation +checks current state before acting; running the script repeatedly produces +the same result with no errors and no duplicate work. + +Usage: + server-setup.py # full setup + server-setup.py --dry-run # preview without changes + server-setup.py --skip-update # skip system update + server-setup.py --skip-packages # skip package installation + server-setup.py --skip-firewall # skip firewall setup + server-setup.py --skip-selinux # skip SELinux + server-setup.py --skip-podman # skip Podman + server-setup.py --skip-auto-updates # skip automatic updates + server-setup.py --version + +Exit status is 0 when every enabled section succeeds, 1 otherwise. +""" + +from __future__ import annotations + +import argparse +import os +import pwd +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.2.0" + +# Timeout in seconds for dnf operations — metadata downloads and package +# transactions on a fresh or slow system routinely exceed the 60 s default +# used for cheap probes. +DNF_TIMEOUT = 1800 + +# Map os-release ID to display name — only these two are supported. +SUPPORTED_OS: dict[str, str] = { + "fedora": "Fedora", + "openeuler": "openEuler", +} + +# Base packages installed on both Fedora and openEuler. +BASE_PACKAGES: list[str] = [ + "nano", + "curl", + "wget", + "htop", + "tmux", + "rsync", + "python3", + "python3-devel", + "nginx", + "openssl", + "jq", + "fastfetch", +] + +# Services to open in firewalld by default — ssh is mandatory, losing it +# means locking out remote administration. +FIREWALL_SERVICES: list[str] = ["ssh"] + +# Opened in firewalld only when nginx is installed (it is in BASE_PACKAGES). +NGINX_FIREWALL_SERVICES: list[str] = ["http", "https"] + +# dnf-automatic configuration file. dnf4 ships it with defaults; dnf5 reads +# host overrides from this path (defaults: /usr/share/dnf5/dnf5-plugins/). +DNF_AUTOMATIC_CONF = "/etc/dnf/automatic.conf" + +# Timer units to probe, in order of preference. dnf4 ships the -install +# variant; dnf5 (Fedora 41+) ships only the single dnf5-automatic.timer. +AUTO_UPDATE_TIMER_CANDIDATES: tuple[str, ...] = ( + "dnf-automatic-install.timer", + "dnf5-automatic.timer", + "dnf-automatic.timer", +) + + +# --------------------------------------------------------------------------- +# Progress helpers — write to stderr so stdout stays clean for machine output +# --------------------------------------------------------------------------- + + +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 = 60, + check: bool = False, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Execute a command and return the CompletedProcess. + + Sets LC_ALL=C for reliable English-language output parsing. + Raises subprocess.CalledProcessError when *check* is True and the + command exits non-zero. A missing executable or an expired timeout is + reported and returned as a synthetic failure (rc 127 / 124) instead of + raising, so callers always get a CompletedProcess to inspect. + """ + merged_env = {**os.environ, "LC_ALL": "C"} + if env: + merged_env.update(env) + try: + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + env=merged_env, + check=check, + ) + except FileNotFoundError: + _fail(f"Command not found: {cmd[0]}") + return subprocess.CompletedProcess(cmd, 127, "", f"command not found: {cmd[0]}") + except subprocess.TimeoutExpired: + _fail(f"Command timed out after {timeout}s: {' '.join(cmd)}") + return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout}s") + + +# --------------------------------------------------------------------------- +# 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("=") + # Strip surrounding quotes if present. + 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 server-setup.py", + file=sys.stderr, + ) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Section helpers +# --------------------------------------------------------------------------- + + +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 _dnf_install(packages: list[str]) -> bool: + """Install packages via dnf in a single transaction. Returns True on success.""" + if not packages: + return True + result = run(["dnf", "install", "-y", *packages], timeout=DNF_TIMEOUT) + return result.returncode == 0 + + +# --------------------------------------------------------------------------- +# 1. System update +# --------------------------------------------------------------------------- + + +def system_update(dry_run: bool = False) -> dict[str, Any]: + """Update the system via dnf. + + Returns a dict with summary info: {'updated': bool, 'skipped': bool}. + """ + info: dict[str, Any] = { + "updated": False, + "skipped": False, + "failed": False, + "reboot_required": False, + } + + _status("Checking for system updates") + check_result = run(["dnf", "check-update"], timeout=DNF_TIMEOUT) + # dnf check-update: 0 = no updates, 100 = updates available, 1 = error. + if check_result.returncode == 0: + _status_done("already up to date") + info["skipped"] = True + return info + if check_result.returncode != 100: + _status_done("check failed") + _fail("dnf check-update failed — cannot determine update state") + info["failed"] = True + return info + + _status_done("updates available") + + if dry_run: + _info("Would run: dnf update -y") + return info + + _status("Applying system updates") + update_result = run(["dnf", "update", "-y"], timeout=DNF_TIMEOUT) + if update_result.returncode != 0: + _status_done("failed") + _fail("System update returned non-zero exit code") + info["failed"] = True + return info + _status_done() + info["updated"] = True + + # dnf needs-restarting -r: rc 1 plus the message means a reboot is + # required. The plugin (dnf-utils) may be absent; anything unrecognised + # is treated as "unknown" rather than a false positive. + reboot_check = run(["dnf", "needs-restarting", "-r"]) + if reboot_check.returncode == 1 and "Reboot is required" in reboot_check.stdout: + info["reboot_required"] = True + _warn("Reboot required to fully apply updates") + + return info + + +# --------------------------------------------------------------------------- +# 2. Base packages +# --------------------------------------------------------------------------- + + +def install_packages(dry_run: bool = False) -> dict[str, Any]: + """Install base packages, skipping any that are already present. + + Returns a dict with 'installed' (list), 'skipped' (list), 'failed' (list). + """ + installed: list[str] = [] + skipped: list[str] = [] + failed: list[str] = [] + to_install: list[str] = [] + + _status("Checking base packages") + for pkg in BASE_PACKAGES: + if _rpm_installed(pkg): + skipped.append(pkg) + else: + to_install.append(pkg) + + already = len(skipped) + missing = len(to_install) + total = len(BASE_PACKAGES) + _status_done(f"{already}/{total} already installed") + + if not to_install: + _ok("All base packages already present") + return {"installed": installed, "skipped": skipped, "failed": failed} + + _info(f"Installing {missing} package(s): {' '.join(to_install)}") + + if dry_run: + for pkg in to_install: + _info(f" Would install: {pkg}") + installed.append(pkg) + return {"installed": installed, "skipped": skipped, "failed": failed} + + # Single transaction first — faster and atomic. If it fails (e.g. one + # package is unavailable on this distro), fall back to per-package + # installs so the rest still succeed. + _status(f"Installing {missing} package(s) in one transaction") + if _dnf_install(to_install): + _status_done() + installed.extend(to_install) + return {"installed": installed, "skipped": skipped, "failed": failed} + + _status_done("transaction failed — falling back to per-package") + for pkg in to_install: + _status(f"Installing {pkg}") + if _dnf_install([pkg]): + _status_done() + installed.append(pkg) + else: + _status_done("failed") + _fail(f"Failed to install {pkg}") + failed.append(pkg) + + return {"installed": installed, "skipped": skipped, "failed": failed} + + +# --------------------------------------------------------------------------- +# 3. Firewall +# --------------------------------------------------------------------------- + + +def setup_firewall(dry_run: bool = False) -> dict[str, Any]: + """Set up firewalld with default rules. + + SSH is written to the permanent configuration and verified BEFORE the + service is enabled/started (via the offline client, which needs no + running daemon), so a misconfiguration can never lock out a remote + admin. Returns a dict with summary of what was configured. + """ + info: dict[str, Any] = { + "installed": False, + "enabled": False, + "zone_set": False, + "services_added": [], + "services_skipped": [], + "failed": False, + } + + # --- Install firewalld if missing --- + _status("Checking firewalld") + firewalld_present = _rpm_installed("firewalld") + if not firewalld_present: + _status_done("not installed") + if dry_run: + _info("Would install: firewalld") + info["installed"] = True + else: + if _dnf_install(["firewalld"]): + _ok("Installed firewalld") + info["installed"] = True + firewalld_present = True + else: + _fail("Failed to install firewalld") + info["failed"] = True + return info + else: + _status_done("installed") + info["installed"] = True + + # ssh is mandatory; http/https only when nginx is present. + services = list(FIREWALL_SERVICES) + if _rpm_installed("nginx"): + services.extend(NGINX_FIREWALL_SERVICES) + + # --- Permanent rules first, via the offline client (no daemon needed) --- + permanent_changed = False + for service in services: + _status(f"Checking firewall service '{service}'") + if not firewalld_present: + # Dry run on a host without firewalld: nothing to query yet. + _status_done("would add") + info["services_added"].append(service) + permanent_changed = True + continue + query = run(["firewall-offline-cmd", "--zone=public", f"--query-service={service}"]) + if query.returncode == 0: + _status_done("already present") + info["services_skipped"].append(service) + continue + if dry_run: + _status_done("would add") + info["services_added"].append(service) + permanent_changed = True + continue + add = run(["firewall-offline-cmd", "--zone=public", f"--add-service={service}"]) + if add.returncode != 0: + _status_done("failed") + _fail(f"Failed to add service '{service}' to zone 'public'") + info["failed"] = True + if service == "ssh": + _warn("Aborting before firewalld is enabled to prevent lockout") + return info + continue + _status_done("added") + info["services_added"].append(service) + permanent_changed = True + + # --- Lockout gate: never enable firewalld without confirmed SSH access --- + if not dry_run: + verify = run(["firewall-offline-cmd", "--zone=public", "--query-service=ssh"]) + if verify.returncode != 0: + _fail("Cannot confirm that SSH is allowed in zone 'public'") + _warn( + "Aborting firewall setup BEFORE enabling firewalld — remote access would be at risk" + ) + info["failed"] = True + return info + + # --- Enable and start (only reached with SSH confirmed) --- + was_active = run(["systemctl", "is-active", "firewalld.service"]).returncode == 0 + + _status("Enabling firewalld service") + enabled = run(["systemctl", "is-enabled", "firewalld.service"]) + if enabled.returncode != 0: + if dry_run: + _status_done("would enable") + info["enabled"] = True + else: + enable = run(["systemctl", "enable", "firewalld.service"]) + if enable.returncode != 0: + _status_done("failed") + _fail("Failed to enable firewalld.service") + info["failed"] = True + return info + _status_done("enabled") + info["enabled"] = True + else: + _status_done("already enabled") + info["enabled"] = True + + _status("Starting firewalld service") + if not was_active: + if dry_run: + _status_done("would start") + else: + start = run(["systemctl", "start", "firewalld.service"]) + if start.returncode != 0: + _status_done("failed") + _fail("Failed to start firewalld.service") + info["failed"] = True + return info + _status_done("started") + else: + _status_done("already running") + + # --- Default zone (applies to runtime immediately) --- + _status("Checking default zone") + zone_result = run(["firewall-offline-cmd", "--get-default-zone"]) + current_zone = zone_result.stdout.strip() + if current_zone != "public": + if dry_run: + _status_done(f"would change from '{current_zone or '?'}' to 'public'") + info["zone_set"] = True + else: + set_zone = run(["firewall-cmd", "--set-default-zone=public"]) + if set_zone.returncode != 0: + _status_done("failed") + _fail("Failed to set default zone to 'public'") + info["failed"] = True + return info + _status_done(f"changed to 'public' (was '{current_zone}')") + info["zone_set"] = True + else: + _status_done("already 'public'") + + # --- Reload only when a running daemon has drifted from permanent --- + if permanent_changed and was_active: + if dry_run: + _info("Would run: firewall-cmd --reload") + else: + _status("Reloading firewall") + reload_result = run(["firewall-cmd", "--reload"]) + if reload_result.returncode != 0: + _status_done("failed") + _fail("Failed to reload firewalld") + info["failed"] = True + return info + _status_done() + + return info + + +# --------------------------------------------------------------------------- +# 4. SELinux +# --------------------------------------------------------------------------- + + +def setup_selinux(dry_run: bool = False) -> dict[str, Any]: + """Ensure SELinux is enforcing and policycoreutils-python-utils is installed. + + A Disabled -> enforcing transition cannot be performed at runtime + (setenforce fails when the kernel has SELinux off): only the config is + switched and /.autorelabel is created so the next boot relabels the + filesystem before entering enforcing mode. + + Returns a dict with summary of changes. + """ + info: dict[str, Any] = { + "mode_changed": False, + "config_changed": False, + "utils_installed": False, + "current_mode": "unknown", + "reboot_required": False, + "failed": False, + } + + # --- Check current mode --- + _status("Checking SELinux mode") + mode_result = run(["getenforce"]) + if mode_result.returncode != 0: + _status_done("failed") + _fail("Cannot determine SELinux mode (getenforce failed or is missing)") + _info("On systems without SELinux, re-run with --skip-selinux") + info["failed"] = True + return info + current_mode = mode_result.stdout.strip() + info["current_mode"] = current_mode + _status_done(current_mode) + + if current_mode == "Permissive": + if dry_run: + _info("Would set SELinux to enforcing mode") + info["mode_changed"] = True + else: + _status("Setting SELinux to enforcing") + enforce = run(["setenforce", "1"]) + if enforce.returncode != 0: + _status_done("failed") + _fail("setenforce 1 failed") + info["failed"] = True + else: + _status_done() + info["mode_changed"] = True + elif current_mode == "Disabled": + # setenforce cannot work here — config + autorelabel below instead. + info["reboot_required"] = True + + # --- Ensure SELINUX=enforcing in config --- + _status("Checking /etc/selinux/config") + config_changed = False + try: + with open("/etc/selinux/config", encoding="utf-8") as fh: + config_content = fh.read() + + has_enforcing = False + lines = config_content.splitlines() + for line in lines: + stripped = line.strip() + if stripped.startswith("SELINUX=") and not stripped.startswith("#"): + value = stripped.split("=", 1)[1].strip() + if value.lower() == "enforcing": + has_enforcing = True + break + + if not has_enforcing: + if dry_run: + _status_done("would update to SELINUX=enforcing") + else: + new_lines: list[str] = [] + found = False + for line in lines: + stripped = line.strip() + if stripped.startswith("SELINUX=") and not stripped.startswith("#"): + new_lines.append("SELINUX=enforcing") + found = True + else: + new_lines.append(line) + if not found: + new_lines.append("SELINUX=enforcing") + with open("/etc/selinux/config", "w", encoding="utf-8") as fh: + fh.write("\n".join(new_lines) + "\n") + _status_done("updated to enforcing") + config_changed = True + else: + _status_done("already enforcing") + except OSError as exc: + _status_done("error") + _fail(f"Cannot read/write /etc/selinux/config: {exc}") + info["failed"] = True + + info["config_changed"] = config_changed + + # --- Disabled -> enforcing requires a reboot with filesystem relabel --- + if current_mode == "Disabled": + if dry_run: + _info("Would create /.autorelabel (relabel on next boot)") + else: + _status("Scheduling filesystem relabel on next boot") + try: + if not os.path.exists("/.autorelabel"): + with open("/.autorelabel", "w", encoding="utf-8"): + pass + _status_done("/.autorelabel created") + except OSError as exc: + _status_done("failed") + _fail(f"Cannot create /.autorelabel: {exc}") + info["failed"] = True + _warn( + "SELinux is Disabled — enforcing mode requires a REBOOT; " + "the filesystem will be relabelled on next boot" + ) + + # --- Install policycoreutils-python-utils --- + _status("Checking policycoreutils-python-utils") + if not _rpm_installed("policycoreutils-python-utils"): + _status_done("not installed") + if dry_run: + _info("Would install: policycoreutils-python-utils") + info["utils_installed"] = True + else: + if _dnf_install(["policycoreutils-python-utils"]): + _ok("Installed policycoreutils-python-utils") + info["utils_installed"] = True + else: + _fail("Failed to install policycoreutils-python-utils") + info["failed"] = True + else: + _status_done("already installed") + + return info + + +# --------------------------------------------------------------------------- +# 5. Podman +# --------------------------------------------------------------------------- + + +def _subid_entry_exists(path: str, user: str) -> bool: + """Return True if *user* already has an entry in a subordinate ID file.""" + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + if line.split(":", 1)[0] == user: + return True + except OSError: + pass + return False + + +def ensure_rootless_subids(dry_run: bool = False) -> None: + """Allocate subordinate UID/GID ranges for the sudo-invoking user. + + Rootless Podman needs a 65536-wide subuid/subgid range per user. The + range is derived deterministically from the user's UID so re-runs are + stable, and existing entries are never modified. Failures are warnings + only — rootless use is optional. + """ + user = os.environ.get("SUDO_USER", "") + if not user or user == "root": + _info("No non-root invoking user — skipping rootless subuid/subgid setup") + return + try: + uid = pwd.getpwnam(user).pw_uid + except KeyError: + _warn(f"Cannot look up user '{user}' — skipping subuid/subgid setup") + return + + start = 100000 + max(uid - 1000, 0) * 65536 + id_range = f"{start}-{start + 65535}" + for path, flag in (("/etc/subuid", "--add-subuids"), ("/etc/subgid", "--add-subgids")): + if _subid_entry_exists(path, user): + _info(f"{path}: entry for '{user}' already present") + continue + if dry_run: + _info(f"Would run: usermod {flag} {id_range} {user}") + continue + _status(f"Allocating subordinate IDs for '{user}' in {path}") + result = run(["usermod", flag, id_range, user]) + if result.returncode == 0: + _status_done(id_range) + else: + _status_done("failed") + _warn(f"usermod {flag} failed — rootless Podman may not work for '{user}'") + + +def setup_podman(dry_run: bool = False) -> dict[str, Any]: + """Install Podman if not present and prepare rootless use. + + Returns a dict with 'installed' (bool), 'version' (str), 'failed' (bool). + """ + info: dict[str, Any] = {"installed": False, "version": "", "failed": False} + + _status("Checking Podman") + already = _rpm_installed("podman") + + if already: + # Verify it actually works. + ver = run(["podman", "--version"]) + if ver.returncode == 0: + info["version"] = ver.stdout.strip() + _status_done(info["version"]) + else: + _status_done("installed but not working") + _warn("podman package is installed but 'podman --version' failed") + info["installed"] = True + ensure_rootless_subids(dry_run) + return info + + _status_done("not installed") + + if dry_run: + _info("Would install: podman") + ensure_rootless_subids(dry_run) + return info + + _status("Installing Podman") + if _dnf_install(["podman"]): + ver = run(["podman", "--version"]) + info["version"] = ver.stdout.strip() + _status_done(info["version"]) + info["installed"] = True + else: + _status_done("failed") + _fail("Failed to install Podman") + info["failed"] = True + return info + + ensure_rootless_subids(dry_run) + return info + + +# --------------------------------------------------------------------------- +# 6. Automatic updates (dnf-automatic) +# --------------------------------------------------------------------------- + + +def _render_dnf_automatic_conf(lines: list[str], desired: dict[str, str]) -> tuple[list[str], bool]: + """Apply *desired* keys to the [commands] section of automatic.conf lines. + + Returns (new_lines, changed). Existing keys are rewritten only when the + value actually differs; missing keys are appended at the end of the + [commands] section; a missing [commands] section is created. Keys in + other sections and comment lines are left untouched. + """ + new_lines: list[str] = [] + seen: set[str] = set() + in_commands = False + found_commands = False + changed = False + + def flush_missing() -> None: + nonlocal changed + for key, val in desired.items(): + if key not in seen: + new_lines.append(f"{key} = {val}\n") + changed = True + + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + if in_commands: + flush_missing() + seen.clear() + in_commands = stripped.lower() == "[commands]" + found_commands = found_commands or in_commands + new_lines.append(line) + continue + if in_commands and "=" in stripped and not stripped.startswith("#"): + key, _, raw_value = stripped.partition("=") + key = key.strip() + if key in desired: + seen.add(key) + if raw_value.strip().lower() != desired[key].lower(): + new_lines.append(f"{key} = {desired[key]}\n") + changed = True + continue + new_lines.append(line) + + if in_commands: + flush_missing() + if not found_commands: + if new_lines and new_lines[-1].strip(): + new_lines.append("\n") + new_lines.append("[commands]\n") + flush_missing() + + return new_lines, changed + + +def setup_auto_updates(os_id: str, dry_run: bool = False) -> dict[str, Any]: + """Configure and enable automatic system updates. + + Fedora: dnf-automatic (security updates via daily timer). The package, + config path and timer unit names differ between dnf4 and dnf5 — all are + probed at runtime. + openEuler: dnf-hotpatch-plugin + custom systemd service/timer applying + all updates plus kernel hot patches. + + Returns a dict with summary of what was configured. + """ + info: dict[str, Any] = { + "installed": False, + "configured": False, + "timer_enabled": False, + "method": os_id, + "failed": False, + } + + if os_id == "fedora": + # --- Install dnf-automatic (dnf4 name; provided virtually on dnf5) --- + _status("Checking dnf-automatic (Fedora)") + pkg_present = _rpm_installed("dnf-automatic") or _rpm_installed("dnf5-plugin-automatic") + if not pkg_present: + _status_done("not installed") + if dry_run: + _info("Would install: dnf-automatic") + info["installed"] = True + else: + if _dnf_install(["dnf-automatic"]) or _dnf_install(["dnf5-plugin-automatic"]): + _ok("Installed dnf-automatic") + info["installed"] = True + pkg_present = True + else: + _fail("Failed to install dnf-automatic") + info["failed"] = True + return info + else: + _status_done("installed") + info["installed"] = True + + # --- Configure /etc/dnf/automatic.conf --- + _status(f"Configuring {DNF_AUTOMATIC_CONF}") + desired = { + "apply_updates": "yes", + "download_updates": "yes", + "upgrade_type": "security", + } + try: + with open(DNF_AUTOMATIC_CONF, encoding="utf-8") as fh: + config_lines = fh.readlines() + except FileNotFoundError: + # dnf5: the host override file is created from scratch. + config_lines = [] + except OSError as exc: + _status_done("cannot read config") + _fail(f"Cannot read {DNF_AUTOMATIC_CONF}: {exc}") + info["failed"] = True + return info + + rendered, changed = _render_dnf_automatic_conf(config_lines, desired) + if not changed: + _status_done("already configured") + info["configured"] = True + elif dry_run: + _status_done("would update") + info["configured"] = True + else: + try: + with open(DNF_AUTOMATIC_CONF, "w", encoding="utf-8") as fh: + fh.writelines(rendered) + except OSError as exc: + _status_done("failed") + _fail(f"Cannot write {DNF_AUTOMATIC_CONF}: {exc}") + info["failed"] = True + return info + _status_done("updated") + info["configured"] = True + + # --- Pick the available timer unit (names differ between dnf4/dnf5) --- + _status("Detecting automatic update timer") + timer_name = "" + if pkg_present: + for candidate in AUTO_UPDATE_TIMER_CANDIDATES: + if run(["systemctl", "cat", candidate]).returncode == 0: + timer_name = candidate + break + if not timer_name: + if dry_run and not pkg_present: + _status_done("would detect after installation") + _info("Would enable and start the automatic update timer") + info["timer_enabled"] = True + return info + _status_done("none found") + _fail( + "No automatic update timer found (tried: " + + ", ".join(AUTO_UPDATE_TIMER_CANDIDATES) + + ")" + ) + info["failed"] = True + return info + _status_done(timer_name) + + # --- Enable and start the timer --- + _status(f"Checking {timer_name}") + timer_enabled = run(["systemctl", "is-enabled", timer_name]) + if timer_enabled.returncode != 0: + if dry_run: + _status_done("would enable") + info["timer_enabled"] = True + else: + enable = run(["systemctl", "enable", timer_name]) + if enable.returncode != 0: + _status_done("failed") + _fail(f"Failed to enable {timer_name}") + info["failed"] = True + return info + _status_done("enabled") + info["timer_enabled"] = True + else: + _status_done("already enabled") + info["timer_enabled"] = True + + _status(f"Starting {timer_name}") + timer_active = run(["systemctl", "is-active", timer_name]) + if timer_active.returncode != 0: + if dry_run: + _status_done("would start") + else: + start = run(["systemctl", "start", timer_name]) + if start.returncode != 0: + _status_done("failed") + _fail(f"Failed to start {timer_name}") + info["failed"] = True + return info + _status_done("started") + else: + _status_done("already running") + + elif os_id == "openeuler": + # --- Install dnf-hotpatch-plugin --- + _status("Checking dnf-hotpatch-plugin (openEuler)") + if not _rpm_installed("dnf-hotpatch-plugin"): + _status_done("not installed") + if dry_run: + _info("Would install: dnf-hotpatch-plugin") + info["installed"] = True + else: + if _dnf_install(["dnf-hotpatch-plugin"]): + _ok("Installed dnf-hotpatch-plugin") + info["installed"] = True + else: + _fail("Failed to install dnf-hotpatch-plugin") + info["failed"] = True + return info + else: + _status_done("installed") + info["installed"] = True + + # --- Create or refresh auto-update.service / auto-update.timer --- + # Policy: openEuler applies ALL available updates (the Fedora + # counterpart applies security-only updates via dnf-automatic). + service_path = "/etc/systemd/system/auto-update.service" + service_content = """\ +[Unit] +Description=Automatic system updates +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# Apply all updates, then activate any kernel hot patches. The leading +# '-' keeps the unit successful when no hot patches are available. +ExecStart=/usr/bin/dnf update -y +ExecStart=-/usr/bin/dnf hotupgrade -y +""" + timer_path = "/etc/systemd/system/auto-update.timer" + timer_content = """\ +[Unit] +Description=Automatic system updates timer + +[Timer] +OnCalendar=daily +RandomizedDelaySec=3600 +Persistent=true + +[Install] +WantedBy=timers.target +""" + needs_reload = False + for unit_path, unit_content in ( + (service_path, service_content), + (timer_path, timer_content), + ): + unit_name = unit_path.rsplit("/", 1)[-1] + _status(f"Checking {unit_name} (openEuler)") + try: + with open(unit_path, encoding="utf-8") as fh: + existing: str | None = fh.read() + except OSError: + existing = None + if existing == unit_content: + _status_done("up to date") + continue + if dry_run: + _status_done("would create" if existing is None else "would update (drift)") + continue + try: + with open(unit_path, "w", encoding="utf-8") as fh: + fh.write(unit_content) + except OSError as exc: + _status_done("failed") + _fail(f"Cannot write {unit_path}: {exc}") + info["failed"] = True + continue + _status_done("created" if existing is None else "updated (content drift)") + needs_reload = True + info["configured"] = True + + # Reload systemd if we created or refreshed unit files + if needs_reload and not dry_run: + _status("Reloading systemd daemon") + daemon_reload = run(["systemctl", "daemon-reload"]) + if daemon_reload.returncode != 0: + _status_done("failed") + _fail("systemctl daemon-reload failed") + info["failed"] = True + return info + _status_done("reloaded") + + # --- Enable auto-update.timer --- + _status("Checking auto-update.timer (openEuler)") + timer_enabled = run(["systemctl", "is-enabled", "auto-update.timer"]) + if timer_enabled.returncode != 0: + if dry_run: + _status_done("would enable") + info["timer_enabled"] = True + else: + enable = run(["systemctl", "enable", "auto-update.timer"]) + if enable.returncode != 0: + _status_done("failed") + _fail("Failed to enable auto-update.timer") + info["failed"] = True + return info + _status_done("enabled") + info["timer_enabled"] = True + else: + _status_done("already enabled") + info["timer_enabled"] = True + + # --- Start auto-update.timer --- + _status("Starting auto-update.timer") + timer_active = run(["systemctl", "is-active", "auto-update.timer"]) + if timer_active.returncode != 0: + if dry_run: + _status_done("would start") + else: + start = run(["systemctl", "start", "auto-update.timer"]) + if start.returncode != 0: + _status_done("failed") + _fail("Failed to start auto-update.timer") + info["failed"] = True + return info + _status_done("started") + else: + _status_done("already running") + + # --- Verify the hotpatch plugin works (tri-state: None = unchecked) --- + info["hotpatch_verified"] = None + _status("Verifying dnf hotpatch plugin (openEuler)") + if dry_run: + _status_done("skipped (dry run)") + else: + hotpatch = run( + ["dnf", "hot-updateinfo", "list", "cves", "--installed"], + timeout=DNF_TIMEOUT, + ) + if hotpatch.returncode == 0: + _status_done("verified") + info["hotpatch_verified"] = True + else: + _status_done("failed") + _warn("dnf hot-updateinfo returned non-zero — hotpatch may not be functional") + info["hotpatch_verified"] = False + + return info + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def print_summary( + os_display: str, + version_id: str, + results: dict[str, Any], + warnings: list[str], + failures: list[str], + elapsed: float, +) -> None: + """Print a final summary of all sections to stderr.""" + print(f"\n{BOLD}── Setup Summary ──{RESET}", file=sys.stderr) + print(f" OS: {os_display} {version_id}", file=sys.stderr) + + # System update + ru = results.get("update", {}) + if ru.get("failed"): + _fail("System update failed") + elif ru.get("updated"): + _ok("System updated") + elif ru.get("skipped"): + _info("System already up to date") + else: + _info("System update skipped") + if ru.get("reboot_required"): + _warn("Reboot required to fully apply updates") + + # Packages + rp = results.get("packages", {}) + installed = len(rp.get("installed", [])) + skipped = len(rp.get("skipped", [])) + failed_pkgs = len(rp.get("failed", [])) + _ok(f"Packages: {skipped} already present, {installed} installed") + if failed_pkgs: + _fail(f"{failed_pkgs} package(s) failed to install") + + # Firewall + rf = results.get("firewall", {}) + if rf.get("failed"): + _fail("Firewall setup failed") + elif rf: + svc = len(rf.get("services_added", [])) + svc_skip = len(rf.get("services_skipped", [])) + _ok(f"Firewall: {svc} service(s) added, {svc_skip} already present") + + # SELinux + rs = results.get("selinux", {}) + if rs.get("failed"): + _fail("SELinux setup failed") + elif rs: + _ok(f"SELinux: mode={rs.get('current_mode', '?')}") + if rs.get("reboot_required"): + _warn("SELinux: reboot required — filesystem relabel scheduled (/.autorelabel)") + + # Podman + rpod = results.get("podman", {}) + if rpod.get("failed"): + _fail("Podman installation failed") + else: + pod_ver = rpod.get("version", "") + if pod_ver: + _ok(f"Podman: {pod_ver}") + else: + _info("Podman: not installed") + + # Auto-updates + rau = results.get("auto_updates", {}) + if rau.get("failed"): + _fail("Auto-updates setup failed") + elif rau: + timer = "enabled" if rau.get("timer_enabled") else "not enabled" + _ok(f"Auto-updates: timer {timer}") + if rau.get("hotpatch_verified") is not None: + hp = "verified" if rau["hotpatch_verified"] else "not verified" + _info(f" dnf hotpatch: {hp}") + + if warnings: + print(file=sys.stderr) + for w in warnings: + _warn(w) + + print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + if failures: + _fail(f"Completed with failures in: {', '.join(failures)}") + else: + _ok("All sections completed successfully") + 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 server setup for Fedora Server and openEuler", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be done without making changes", + ) + parser.add_argument( + "--skip-update", + action="store_true", + help="Skip system update", + ) + parser.add_argument( + "--skip-packages", + action="store_true", + help="Skip base package installation", + ) + parser.add_argument( + "--skip-firewall", + action="store_true", + help="Skip firewall setup", + ) + parser.add_argument( + "--skip-selinux", + action="store_true", + help="Skip SELinux configuration", + ) + parser.add_argument( + "--skip-podman", + action="store_true", + help="Skip Podman installation", + ) + parser.add_argument( + "--skip-auto-updates", + action="store_true", + help="Skip automatic updates configuration", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION}", + ) + return parser.parse_args(argv) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + """Entry point — parse args, check root, detect OS, run enabled sections.""" + start_time = time.monotonic() + warnings: list[str] = [] + results: dict[str, Any] = {} + + # Parse arguments first so --help/--version work for any user on any OS. + args = parse_args() + + check_root() + + os_id, os_display, version_id = detect_os() + + # Header + print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + print( + f"{BOLD} Server Setup 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, + ) + print(file=sys.stderr) + + # 1. System update + print(f"\n{BOLD}── System Update ──{RESET}", file=sys.stderr) + if not args.skip_update: + results["update"] = system_update(dry_run=args.dry_run) + if results["update"].get("reboot_required"): + warnings.append("System updates require a reboot to take full effect") + else: + _info("System update: skipped (--skip-update)") + + # 2. Base packages + print(f"\n{BOLD}── Base Packages ──{RESET}", file=sys.stderr) + if not args.skip_packages: + results["packages"] = install_packages(dry_run=args.dry_run) + if results["packages"].get("failed"): + warnings.append( + f"Some packages failed to install: {', '.join(results['packages']['failed'])}" + ) + else: + _info("Base packages: skipped (--skip-packages)") + + # 3. Firewall + print(f"\n{BOLD}── Firewall ──{RESET}", file=sys.stderr) + if not args.skip_firewall: + results["firewall"] = setup_firewall(dry_run=args.dry_run) + else: + _info("Firewall: skipped (--skip-firewall)") + + # 4. SELinux + print(f"\n{BOLD}── SELinux ──{RESET}", file=sys.stderr) + if not args.skip_selinux: + results["selinux"] = setup_selinux(dry_run=args.dry_run) + else: + _info("SELinux: skipped (--skip-selinux)") + + # 5. Podman + print(f"\n{BOLD}── Podman ──{RESET}", file=sys.stderr) + if not args.skip_podman: + results["podman"] = setup_podman(dry_run=args.dry_run) + else: + _info("Podman: skipped (--skip-podman)") + + # 6. Automatic updates + print(f"\n{BOLD}── Automatic Updates ──{RESET}", file=sys.stderr) + if not args.skip_auto_updates: + results["auto_updates"] = setup_auto_updates(os_id, dry_run=args.dry_run) + else: + _info("Auto-updates: skipped (--skip-auto-updates)") + + # Any truthy "failed" value (bool flag or non-empty package list) counts. + failures = [name for name, res in results.items() if res.get("failed")] + + elapsed = time.monotonic() - start_time + print_summary(os_display, version_id, results, warnings, failures, elapsed) + + if failures: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/system-diag.py b/system-diag.py new file mode 100644 index 0000000..a5afae7 --- /dev/null +++ b/system-diag.py @@ -0,0 +1,1823 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +# SPDX-License-Identifier: MIT + +"""System diagnostics — CPU, memory, disk, network, GPU, services, security, performance. + +No external dependencies — leverages /proc, /sys, and system tools with +graceful fallbacks when any data source is unavailable. + +Usage: + system-diag.py # full report + system-diag.py --section cpu,memory # only selected sections + system-diag.py --external-ip # also detect external IPs (3rd-party) + system-diag.py --json # machine-readable output + system-diag.py --version + +Linux only. Progress is written to stderr, the report to stdout. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import time +from collections.abc import Callable +from concurrent.futures import Future, ThreadPoolExecutor, as_completed +from typing import Any + +BOLD = "\033[1m" +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +MAGENTA = "\033[35m" +DIM = "\033[2m" +RESET = "\033[0m" + + +def _supports_colour() -> bool: + """Honour NO_COLOR (https://no-color.org) and non-TTY output.""" + if os.environ.get("NO_COLOR") is not None: + return False + return sys.stdout.isatty() + + +if not _supports_colour(): + BOLD = RED = GREEN = YELLOW = CYAN = MAGENTA = DIM = RESET = "" + +# Letter grade → colour, applied at render time only (never stored in data). +GRADE_COLOURS = {"A": GREEN, "B": GREEN, "C": YELLOW, "D": YELLOW, "F": RED} + +VERSION = "1.0.0" + +WIDTH = 60 + +ALL_SECTIONS = [ + "overview", + "cpu", + "memory", + "disk", + "network", + "gpu", + "services", + "security", + "performance", + "issues", +] + + +def _status(msg: str) -> None: + """Print progress message to stderr — stays on 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 run(cmd: list[str], timeout: int = 15) -> tuple[str, str]: + """Execute a shell command and return (stdout, stderr) separately. + + Sets LC_ALL=C (highest locale precedence, overrides user settings) for + reliable English-language output parsing. + Returns empty strings on timeout or if the command cannot be run. + """ + try: + env = {**os.environ, "LANG": "C", "LC_ALL": "C"} + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + env=env, + errors="replace", + ) + return result.stdout, result.stderr + except (subprocess.TimeoutExpired, OSError): + return "", "" + + +def _read_file(path: str) -> str: + """Read a file, returning '' if it doesn't exist or can't be read.""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + return f.read().strip() + except OSError: + return "" + + +def _read_lines(path: str) -> list[str]: + """Read lines from a file, returning [] if unavailable.""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + return [line.strip() for line in f] + except OSError: + return [] + + +def _bar(value: float, maximum: float = 100.0, width: int = 20) -> str: + """Build a textual usage bar with colour. + + Returns a string like '████████░░░░░░░░░░░░ 42%' with ANSI colour. + """ + ratio = max(0.0, min(1.0, value / maximum)) if maximum > 0 else 0.0 + filled = int(ratio * width) + if ratio < 0.80: + colour = GREEN + elif ratio < 0.90: + colour = YELLOW + else: + colour = RED + bar_str = colour + "█" * filled + DIM + "░" * (width - filled) + RESET + return f"{bar_str} {ratio * 100:.0f}%" + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: System Overview +# ────────────────────────────────────────────────────────────────────────────── + + +def _parse_os_release() -> dict[str, str]: + """Parse /etc/os-release into a dict.""" + info: dict[str, str] = {} + for line in _read_lines("/etc/os-release"): + if "=" in line: + key, _, val = line.partition("=") + info[key.strip()] = val.strip().strip('"') + return info + + +def _uptime_human(seconds: float) -> str: + """Return human-readable uptime string, e.g. '3 days, 7 hours'.""" + days, rem = divmod(int(seconds), 86400) + hours, rem = divmod(rem, 3600) + minutes = rem // 60 + + parts = [] + if days: + parts.append(f"{days} day{'s' if days != 1 else ''}") + if hours: + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + if minutes or not parts: + parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + return ", ".join(parts) + + +def _boot_time() -> str: + """Return boot time from /proc/stat (btime).""" + for line in _read_lines("/proc/stat"): + if line.startswith("btime "): + try: + ts = int(line.split()[1]) + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) + except (ValueError, IndexError): + pass + return "unknown" + + +def _load_average() -> tuple[float, float, float]: + """Return (1min, 5min, 15min) load averages.""" + content = _read_file("/proc/loadavg") + try: + parts = content.split() + return float(parts[0]), float(parts[1]), float(parts[2]) + except (IndexError, ValueError): + return (0.0, 0.0, 0.0) + + +def _logged_users() -> int: + """Return the number of unique users with a login session (via ``who``).""" + out, _ = run(["who"]) + users = {line.split()[0] for line in out.split("\n") if line.strip()} + return len(users) + + +def collect_overview() -> dict[str, Any]: + """Collect system overview data.""" + os_info = _parse_os_release() + os_name = os_info.get("PRETTY_NAME", os_info.get("NAME", "unknown")) + load1, load5, load15 = _load_average() + + uptime_seconds = 0.0 + uptime_raw = _read_file("/proc/uptime") + if uptime_raw: + try: + uptime_seconds = float(uptime_raw.split()[0]) + except (ValueError, IndexError): + uptime_seconds = 0.0 + + return { + "hostname": socket.gethostname() or os.uname().nodename, + "os": os_name, + "kernel": os.uname().release, + "uptime_seconds": uptime_seconds, + "uptime_human": _uptime_human(uptime_seconds), + "load_1min": load1, + "load_5min": load5, + "load_15min": load15, + "cpu_cores": os.cpu_count() or 1, + "users_logged": _logged_users(), + "boot_time": _boot_time(), + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: CPU +# ────────────────────────────────────────────────────────────────────────────── + + +def _cpu_model() -> str: + """Get CPU model name from /proc/cpuinfo.""" + for line in _read_lines("/proc/cpuinfo"): + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + return "unknown" + + +def _cpu_arch() -> str: + """Get CPU architecture.""" + return os.uname().machine + + +def _cpu_cores_threads() -> tuple[int, int]: + """Return (physical cores, logical threads). + + "core id" is unique only within one physical package ("physical id"), + so the (package, core) pair is needed on multi-socket systems. + """ + cpuinfo = _read_file("/proc/cpuinfo") + cores: set[tuple[str, str]] = set() + threads = 0 + physical_id = "0" + for line in cpuinfo.split("\n"): + if line.startswith("physical id"): + physical_id = line.split(":", 1)[1].strip() + elif line.startswith("core id"): + cores.add((physical_id, line.split(":", 1)[1].strip())) + elif line.startswith("processor"): + threads += 1 + phys_count = len(cores) if cores else (os.cpu_count() or 1) + return phys_count, threads if threads else (os.cpu_count() or 1) + + +def _cpu_frequency() -> str: + """Get current CPU frequency in MHz.""" + # Try /proc/cpuinfo first + for line in _read_lines("/proc/cpuinfo"): + if "cpu mhz" in line.lower(): + return line.split(":", 1)[1].strip() + " MHz" + # Try /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq + freq = _read_file("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") + if freq: + try: + return f"{int(freq) / 1000:.0f} MHz" + except ValueError: + pass + return "unknown" + + +def _cpu_temperature() -> str: + """Get CPU temperature from hwmon sensors.""" + base = "/sys/class/hwmon" + if not os.path.isdir(base): + return "unknown" + + for entry in sorted(os.listdir(base)): + name = _read_file(os.path.join(base, entry, "name")) + if name.lower() in ("coretemp", "k10temp", "cpu_thermal", "acpitz"): + for i in range(1, 5): + temp_raw = _read_file(os.path.join(base, entry, f"temp{i}_input")) + if temp_raw: + try: + temp_c = int(temp_raw) / 1000.0 + return f"{temp_c:.1f}°C" + except ValueError: + pass + # Fallback: try temp1_input without label + temp_raw = _read_file(os.path.join(base, entry, "temp1_input")) + if temp_raw: + try: + return f"{int(temp_raw) / 1000.0:.1f}°C" + except ValueError: + pass + + return "unknown" + + +def _parse_cpu_fields(line: str) -> list[int]: + """Parse a /proc/stat cpu line into jiffies, dropping the guest columns. + + guest and guest_nice are already included in user and nice, so keeping + them in the total would double-count. + """ + try: + return [int(x) for x in line.split()[1:9]] + except ValueError: + return [] + + +def _read_cpu_times() -> tuple[list[int], list[list[int]]]: + """Read aggregate and per-CPU jiffy counters from /proc/stat.""" + aggregate: list[int] = [] + per_core: list[list[int]] = [] + for line in _read_lines("/proc/stat"): + if line.startswith("cpu "): + aggregate = _parse_cpu_fields(line) + elif line[:3] == "cpu" and line[3:4].isdigit(): + per_core.append(_parse_cpu_fields(line)) + return aggregate, per_core + + +def _usage_percent(times1: list[int], times2: list[int]) -> float: + """Compute usage percentage between two jiffy snapshots. + + iowait counts as idle time — a process waiting on disk is not using CPU. + """ + if len(times1) < 5 or len(times2) < 5: + return 0.0 + idle_delta = (times2[3] + times2[4]) - (times1[3] + times1[4]) + total_delta = sum(times2) - sum(times1) + if total_delta <= 0: + return 0.0 + return round((1 - idle_delta / total_delta) * 100, 1) + + +def _cpu_usage(sample_interval: float = 0.2) -> tuple[float, list[dict[str, Any]]]: + """Sample aggregate and per-core CPU usage over a short interval. + + Both are computed from the same sampling window, so the per-core view + matches the aggregate instead of showing since-boot averages. + """ + agg1, cores1 = _read_cpu_times() + time.sleep(sample_interval) + agg2, cores2 = _read_cpu_times() + + per_core = [ + {"cpu": str(index), "usage_pct": _usage_percent(c1, c2)} + for index, (c1, c2) in enumerate(zip(cores1, cores2)) + ] + return _usage_percent(agg1, agg2), per_core + + +def collect_cpu() -> dict[str, Any]: + """Collect CPU data.""" + phys_cores, threads = _cpu_cores_threads() + usage, per_core = _cpu_usage() + + return { + "model": _cpu_model(), + "architecture": _cpu_arch(), + "physical_cores": phys_cores, + "threads": threads, + "frequency": _cpu_frequency(), + "temperature": _cpu_temperature(), + "usage_pct": usage, + "per_core": per_core, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: Memory +# ────────────────────────────────────────────────────────────────────────────── + + +def _parse_meminfo() -> dict[str, int]: + """Parse /proc/meminfo into {key: value_in_kb}.""" + mem: dict[str, int] = {} + for line in _read_lines("/proc/meminfo"): + parts = line.split() + if len(parts) >= 2: + key = parts[0].rstrip(":") + try: + mem[key] = int(parts[1]) + except ValueError: + pass + return mem + + +def _top_mem_processes(count: int = 5) -> list[dict[str, Any]]: + """Return top N memory-consuming processes using ps.""" + out, _ = run( + ["ps", "-eo", "pid,user,rss,comm", "--sort=-rss", "--no-headers"], + timeout=5, + ) + procs: list[dict[str, Any]] = [] + for line in out.strip().split("\n")[:count]: + parts = line.split(None, 3) + if len(parts) >= 4: + try: + rss_kb = int(parts[2]) + procs.append( + { + "pid": int(parts[0]), + "user": parts[1], + "rss_mb": round(rss_kb / 1024, 1), + "command": parts[3], + } + ) + except ValueError: + pass + return procs + + +def collect_memory() -> dict[str, Any]: + """Collect memory data.""" + mem = _parse_meminfo() + + total = mem.get("MemTotal", 0) // 1024 + free = mem.get("MemFree", 0) // 1024 + available = mem.get("MemAvailable", 0) // 1024 + used = total - available if available else total - free + + swap_total = mem.get("SwapTotal", 0) // 1024 + swap_free = mem.get("SwapFree", 0) // 1024 + swap_used = swap_total - swap_free + + usage_pct = round((used / total) * 100, 1) if total > 0 else 0.0 + swap_pct = round((swap_used / swap_total) * 100, 1) if swap_total > 0 else 0.0 + + return { + "total_mb": total, + "used_mb": used, + "free_mb": free, + "available_mb": available, + "usage_pct": usage_pct, + "swap_total_mb": swap_total, + "swap_used_mb": swap_used, + "swap_usage_pct": swap_pct, + "top_processes": _top_mem_processes(), + "buffers_mb": mem.get("Buffers", 0) // 1024, + "cached_mb": mem.get("Cached", 0) // 1024, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: Disk +# ────────────────────────────────────────────────────────────────────────────── + + +def _skip_filesystem(fs_type: str, device: str) -> bool: + """Return True if this filesystem should be excluded.""" + excluded = { + "tmpfs", + "devtmpfs", + "squashfs", + "overlay", + "proc", + "sysfs", + "devpts", + "cgroup", + "cgroup2", + "pstore", + "bpf", + "debugfs", + "tracefs", + "securityfs", + "hugetlbfs", + "fuse.gvfsd-fuse", + "fuse.portal", + "ramfs", + "efivarfs", + "configfs", + "selinuxfs", + "systemd-1", + "mqueue", + "fusectl", + "sunrpc", + "binfmt_misc", + "autofs", + "rpc_pipefs", + "nsfs", + } + if fs_type in excluded: + return True + if device.startswith("tmpfs") or device.startswith("devtmpfs"): + return True + return False + + +def _mounts() -> list[dict[str, Any]]: + """Parse /proc/mounts for real filesystems. + + ``used`` is derived from ``f_bavail`` (space available to unprivileged + users), so root-reserved blocks count as used — slightly more + conservative than ``df``, which is deliberate for a health report. + Bind mounts and btrfs subvolumes share the underlying device, so + entries are deduplicated by device id to keep the totals honest. + """ + fs_list: list[dict[str, Any]] = [] + seen_devices: set[int] = set() + for line in _read_lines("/proc/mounts"): + parts = line.split() + if len(parts) < 6: + continue + device, mountpoint, fs_type = parts[0], parts[1], parts[2] + if _skip_filesystem(fs_type, device): + continue + mountpoint = mountpoint.replace("\\040", " ") # spaces are escaped in /proc/mounts + try: + st_dev = os.stat(mountpoint).st_dev + if st_dev in seen_devices: + continue + seen_devices.add(st_dev) + stat = os.statvfs(mountpoint) + total = stat.f_blocks * stat.f_frsize + available = stat.f_bavail * stat.f_frsize + used = total - available + size_gb = total / (1024**3) + used_gb = used / (1024**3) + avail_gb = available / (1024**3) + use_pct = round((used / total) * 100, 1) if total > 0 else 0.0 + except OSError: + continue + + fs_list.append( + { + "device": device, + "mountpoint": mountpoint, + "fstype": fs_type, + "size_gb": round(size_gb, 1), + "used_gb": round(used_gb, 1), + "available_gb": round(avail_gb, 1), + "use_pct": use_pct, + } + ) + return fs_list + + +def _disk_io_stats() -> dict[str, Any] | None: + """Collect disk I/O stats using iostat, if available. + + Uses ``iostat -d -x 1 2``: the first report block covers averages since + boot, the second is a live 1-second sample — only the last block is + parsed. Columns are located by header name so that layout differences + between sysstat versions cannot silently misalign the values. + """ + out, _ = run(["iostat", "-d", "-x", "1", "2"], timeout=10) + if not out: + return None + + # Split the output into report blocks, each starting at a Device header. + blocks: list[list[str]] = [] + current: list[str] = [] + for line in out.split("\n"): + if "Device" in line and "r/s" in line: + if current: + blocks.append(current) + current = [line] + elif current: + current.append(line) + if current: + blocks.append(current) + if not blocks: + return None + + lines = blocks[-1] + columns = {name: index for index, name in enumerate(lines[0].split())} + + def _value(parts: list[str], *names: str) -> float: + for name in names: + index = columns.get(name) + if index is not None and index < len(parts): + try: + return float(parts[index]) + except ValueError: + return 0.0 + return 0.0 + + devices: list[dict[str, Any]] = [] + for line in lines[1:]: + parts = line.split() + if not parts or parts[0] == "Device": + continue + reads = _value(parts, "r/s") + writes = _value(parts, "w/s") + devices.append( + { + "device": parts[0], + "tps": round(reads + writes, 2), + "read_mb_s": round(_value(parts, "rkB/s", "rMB/s") / 1024, 2), + "write_mb_s": round(_value(parts, "wkB/s", "wMB/s") / 1024, 2), + } + ) + + return {"devices": devices} if devices else None + + +def _in_container() -> bool: + """Best-effort container detection (Podman/Docker markers, overlay rootfs).""" + if os.path.exists("/run/.containerenv") or os.path.exists("/.dockerenv"): + return True + for line in _read_lines("/proc/mounts"): + parts = line.split() + if len(parts) >= 3 and parts[1] == "/" and parts[2] == "overlay": + return True + return False + + +def collect_disk() -> dict[str, Any]: + """Collect disk data.""" + mounts = _mounts() + total_gb = sum(m["size_gb"] for m in mounts) + used_gb = sum(m["used_gb"] for m in mounts) + critical = [m for m in mounts if m["use_pct"] > 90] + + return { + "filesystems": mounts, + "total_gb": round(total_gb, 1), + "used_gb": round(used_gb, 1), + "usage_pct": round((used_gb / total_gb) * 100, 1) if total_gb > 0 else 0.0, + "critical_fs": critical, + "io_stats": _disk_io_stats(), + "container": _in_container(), + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: Network +# ────────────────────────────────────────────────────────────────────────────── + + +def _net_interfaces() -> list[dict[str, Any]]: + """Enumerate non-loopback network interfaces.""" + ifaces: list[dict[str, Any]] = [] + net_path = "/sys/class/net" + if not os.path.isdir(net_path): + return ifaces + + for iface in sorted(os.listdir(net_path)): + if iface == "lo": + continue + operstate = _read_file(os.path.join(net_path, iface, "operstate")) + speed_raw = _read_file(os.path.join(net_path, iface, "speed")) + speed = f"{speed_raw} Mbps" if speed_raw and speed_raw != "-1" else "unknown" + + # Get IPs via ip command + out, _ = run(["ip", "-br", "addr", "show", iface], timeout=5) + ipv4: list[str] = [] + ipv6: list[str] = [] + if out: + parts = out.split() + skip_next = False + for part in parts[2:]: + if part == "peer": + skip_next = True # next token is the remote end's address + continue + if skip_next: + skip_next = False + continue + if "." in part and "/" in part: + ipv4.append(part.split("/")[0]) + elif ":" in part and "/" in part: + ipv6.append(part.split("/")[0]) + + ifaces.append( + { + "name": iface, + "state": operstate.upper() if operstate else "UNKNOWN", + "speed": speed, + "ipv4": ipv4, + "ipv6": ipv6, + } + ) + return ifaces + + +def _connection_summary() -> dict[str, int]: + """Get connection summary via ss -s.""" + out, _ = run(["ss", "-s"], timeout=5) + result: dict[str, int] = {} + for line in out.split("\n"): + if "TCP:" in line: + match = re.search(r"estab\s+(\d+)", line) + if match: + result["tcp_established"] = int(match.group(1)) + if "Total:" in line: + match = re.search(r"(\d+)", line) + if match: + result["total"] = int(match.group(1)) + return result + + +def _default_routes() -> dict[str, str]: + """Get default IPv6 and IPv4 routes (IPv6 first).""" + routes: dict[str, str] = {"v6": "", "v4": ""} + out, _ = run(["ip", "-6", "route", "show", "default"], timeout=5) + if out.strip(): + routes["v6"] = out.strip() + out, _ = run(["ip", "-4", "route", "show", "default"], timeout=5) + if out.strip(): + routes["v4"] = out.strip() + return routes + + +def _external_ip() -> dict[str, str]: + """Detect external IP addresses via icanhazip.com (IPv6 first). + + Only called when the user passes --external-ip: it sends a request to + a third-party service, which also reveals this host's address to it. + """ + result: dict[str, str] = {"v6": "unknown", "v4": "unknown"} + for family, flag in (("v6", "-6"), ("v4", "-4")): + out, _ = run( + ["curl", flag, "--max-time", "5", "-s", "https://icanhazip.com"], + timeout=8, + ) + if out.strip(): + result[family] = out.strip() + return result + + +def collect_network(include_external: bool = False) -> dict[str, Any]: + """Collect network data. External-IP lookup is opt-in.""" + data: dict[str, Any] = { + "interfaces": _net_interfaces(), + "connections": _connection_summary(), + "routes": _default_routes(), + } + if include_external: + data["external_ip"] = _external_ip() + return data + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: GPU +# ────────────────────────────────────────────────────────────────────────────── + + +# Matches rocm-smi data lines across ROCm versions, with or without the +# bracketed card index: "GPU[0]\t\t: Card series:\t…" and "GPU\t\t: …". +_ROCM_LINE = re.compile(r"^GPU(?:\[(\d+)\])?\s*:\s*([^:]+?)\s*:\s*(.+)$") + + +def _rocm_smi_bin() -> str | None: + """Locate rocm-smi: PATH first (distro packages), ROCm default as fallback.""" + found = shutil.which("rocm-smi") + if found: + return found + default = "/opt/rocm/bin/rocm-smi" + return default if os.path.isfile(default) else None + + +def _amd_gpu() -> dict[str, Any] | None: + """Collect AMD GPU info via rocm-smi.""" + binary = _rocm_smi_bin() + if binary is None: + return None + out, _ = run([binary, "--showproductname", "--showuse"], timeout=10) + if not out: + return None + + # Group key/value pairs by card id — every rocm-smi data line is + # prefixed with "GPU[N] :", so looping per line would create one + # "card" per line instead of one card with many properties. + cards: dict[str, dict[str, str]] = {} + for line in out.split("\n"): + match = _ROCM_LINE.match(line.strip()) + if not match: + continue + card_id = match.group(1) or "0" + key = match.group(2).strip() + value = match.group(3).strip() + cards.setdefault(card_id, {"id": card_id})[key] = value + + if not cards: + return None + + info: dict[str, Any] = {"vendor": "AMD", "cards": list(cards.values())} + vram = _rocm_vram(binary) + if vram: + info["vram"] = vram + return info + + +def _rocm_vram(binary: str) -> dict[str, Any] | None: + """Parse ``rocm-smi --showmeminfo vram`` into MiB totals. + + Values are reported in bytes ("VRAM Total Memory (B)") and aggregated + across all cards. Keys are matched exactly: "VRAM Total Used Memory (B)" + contains both the words "Total" and "Used", so substring matching + would double-count. + """ + out, _ = run([binary, "--showmeminfo", "vram"], timeout=10) + if not out: + return None + + total_bytes = 0 + used_bytes = 0 + for line in out.split("\n"): + match = _ROCM_LINE.match(line.strip()) + if not match: + continue + key = match.group(2).strip() + try: + value = int(match.group(3).strip().split()[0]) + except (ValueError, IndexError): + continue + if key == "VRAM Total Used Memory (B)": + used_bytes += value + elif key == "VRAM Total Memory (B)": + total_bytes += value + + if total_bytes <= 0: + return None + mib = 1024 * 1024 + return { + "total_mb": total_bytes // mib, + "used_mb": used_bytes // mib, + "free_mb": (total_bytes - used_bytes) // mib, + } + + +def _nvidia_gpu() -> dict[str, Any] | None: + """Collect NVIDIA GPU info via nvidia-smi.""" + out, _ = run( + [ + "nvidia-smi", + "--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total", + "--format=csv,noheader,nounits", + ], + timeout=10, + ) + if not out: + return None + + cards: list[dict[str, Any]] = [] + for line in out.strip().split("\n"): + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 3: + try: + cards.append( + { + "name": parts[0], + "temperature": parts[1] if len(parts) > 1 else "N/A", + "utilization_pct": parts[2] if len(parts) > 2 else "N/A", + "memory": ( + f"{parts[3]} MiB / {parts[4]} MiB" if len(parts) >= 5 else "N/A" + ), + } + ) + except (ValueError, IndexError): + pass + + return {"vendor": "NVIDIA", "cards": cards} if cards else None + + +def collect_gpu() -> dict[str, Any] | None: + """Collect GPU data. Falls back to lspci when ROCm/nvidia-smi unavailable.""" + nvidia = _nvidia_gpu() + if nvidia: + return nvidia + amd = _amd_gpu() + if amd: + return amd + # Fallback: detect GPU hardware via lspci (no driver needed) + return _lspci_gpu() + + +def _lspci_gpu() -> dict[str, Any] | None: + """Detect GPU hardware via lspci — works even without vendor drivers. + + Returns {"unavailable": True} when lspci itself is not installed, so + the report can say 'detection unavailable' instead of a false 'no GPU'. + """ + if shutil.which("lspci") is None: + return {"unavailable": True} + out, _ = run(["lspci"], timeout=10) + if not out: + return {"unavailable": True} + cards: list[dict[str, str]] = [] + for line in out.split("\n"): + lower = line.lower() + if "vga" in lower or "display" in lower or "3d" in lower: + # Extract model: "01:00.0 VGA compatible controller: AMD ..." + parts = line.split(": ", 2) + model = parts[-1].strip() if len(parts) >= 3 else line.strip() + vendor = "AMD" if "amd" in lower or "radeon" in lower or "ati" in lower else "" + vendor = vendor or ("NVIDIA" if "nvidia" in lower else "") + vendor = vendor or ("Intel" if "intel" in lower else "") + cards.append({"model": model, "vendor": vendor, "driver": "(not loaded)"}) + if not cards: + # Probe for AMD compute devices without a display class (e.g. Instinct) + out2, _ = run(["lspci", "-d", "1002:"], timeout=10) + if out2: + for line in out2.strip().split("\n"): + if line.strip(): + cards.append( + { + "model": line.strip(), + "vendor": "AMD", + "driver": "(not loaded)", + } + ) + if not cards: + return None + return { + "vendor": cards[0]["vendor"] or "unknown", + "cards": cards, + "fallback": True, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: Services +# ────────────────────────────────────────────────────────────────────────────── + + +def _failed_units() -> list[dict[str, str]] | None: + """List failed systemd units, or None when systemctl is unavailable. + + --plain is required: without it, systemd prefixes failed units with a + "●" bullet even when output is piped, which breaks field parsing. + """ + if shutil.which("systemctl") is None: + return None + out, _ = run(["systemctl", "--failed", "--no-legend", "--no-pager", "--plain"], timeout=10) + units: list[dict[str, str]] = [] + for line in out.strip().split("\n"): + parts = line.split(None, 4) + if len(parts) >= 2: + units.append({"unit": parts[0], "state": parts[1]}) + return units + + +def _check_service(name: str) -> dict[str, str]: + """Check status of a named systemd service.""" + out, _ = run(["systemctl", "is-active", name, "--no-pager"], timeout=5) + status = out.strip() if out.strip() else "not-found" + return {"name": name, "status": status} + + +def collect_services() -> dict[str, Any]: + """Collect service data.""" + key_services = ["sshd", "firewalld", "nginx", "postgresql"] + svc_status = {} + for svc in key_services: + svc_status[svc] = _check_service(svc) + + return { + "failed_units": _failed_units(), + "key_services": svc_status, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: Security +# ────────────────────────────────────────────────────────────────────────────── + + +def _selinux_status() -> str: + """Get SELinux mode: Enforcing, Permissive, Disabled, or 'unknown'. + + 'unknown' means getenforce is not installed at all — callers can then + distinguish 'no SELinux tooling' from 'SELinux disabled'. + """ + out, _ = run(["getenforce"], timeout=5) + return out.strip() if out.strip() else "unknown" + + +def _firewalld_info() -> dict[str, Any]: + """Get firewalld status. + + ``available`` is False when firewall-cmd is not installed at all, so + callers can distinguish 'tool absent' from 'firewall disabled'. + """ + result: dict[str, Any] = { + "available": False, + "active": False, + "default_zone": "", + "zones": [], + } + out, _ = run(["firewall-cmd", "--state"], timeout=5) + state = out.strip() + if not state: + return result + result["available"] = True + result["active"] = state == "running" + + out, _ = run(["firewall-cmd", "--get-default-zone"], timeout=5) + if out.strip(): + result["default_zone"] = out.strip() + + out, _ = run(["firewall-cmd", "--get-active-zones"], timeout=5) + if out.strip(): + for line in out.split("\n"): + # Zone names sit at column 0; member lines (" interfaces: …") + # are indented — indentation must be checked before stripping. + if not line.strip() or line.startswith((" ", "\t")): + continue + result["zones"].append(line.strip()) + + return result + + +def _failed_ssh_attempts() -> int: + """Count failed SSH password attempts today.""" + out, _ = run( + ["journalctl", "-u", "sshd", "--since", "today", "--no-pager"], + timeout=10, + ) + if not out: + # Fall back to the "ssh" unit name used by some distros + out, _ = run( + ["journalctl", "-u", "ssh", "--since", "today", "--no-pager"], + timeout=10, + ) + return out.lower().count("failed password") + + +def _last_logins(count: int = 3) -> list[str]: + """Get last N successful logins.""" + out, _ = run(["last", "-n", str(count), "--no-hostname"], timeout=5) + logins: list[str] = [] + for line in out.strip().split("\n"): + if line and "reboot" not in line.lower() and "wtmp" not in line.lower(): + logins.append(line.strip()) + if len(logins) >= count: + break + return logins + + +def _open_ports_count() -> int: + """Count listening TCP ports.""" + out, _ = run(["ss", "-tlnp"], timeout=5) + # Count lines minus header + lines = [entry for entry in out.strip().split("\n") if entry.strip() and "LISTEN" in entry] + return len(lines) + + +def collect_security() -> dict[str, Any]: + """Collect security data.""" + return { + "selinux": _selinux_status(), + "firewalld": _firewalld_info(), + "failed_ssh_attempts": _failed_ssh_attempts(), + "last_logins": _last_logins(), + "open_ports_count": _open_ports_count(), + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: Performance +# ────────────────────────────────────────────────────────────────────────────── + + +def _process_counts() -> dict[str, int]: + """Count total and zombie processes.""" + total = 0 + zombies = 0 + for entry in os.listdir("/proc"): + try: + _pid = int(entry) + total += 1 + status = _read_file(f"/proc/{entry}/status") + for line in status.split("\n"): + if line.startswith("State:"): + if "Z" in line: + zombies += 1 + break + except (ValueError, OSError): + continue + return {"total": total, "zombies": zombies} + + +def _top_cpu_processes(count: int = 5) -> list[dict[str, Any]]: + """Return top N CPU-consuming processes.""" + out, _ = run( + ["ps", "-eo", "pid,user,%cpu,comm", "--sort=-%cpu", "--no-headers"], + timeout=5, + ) + procs: list[dict[str, Any]] = [] + for line in out.strip().split("\n")[:count]: + parts = line.split(None, 3) + if len(parts) >= 4: + try: + procs.append( + { + "pid": int(parts[0]), + "user": parts[1], + "cpu_pct": float(parts[2]), + "command": parts[3], + } + ) + except ValueError: + pass + return procs + + +def _context_switches() -> dict[str, int]: + """Get context switches and interrupts from /proc/stat.""" + result: dict[str, int] = {"ctxt": 0, "intr": 0} + for line in _read_lines("/proc/stat"): + if line.startswith("ctxt "): + try: + result["ctxt"] = int(line.split()[1]) + except (IndexError, ValueError): + pass + if line.startswith("intr "): + try: + result["intr"] = int(line.split()[1]) + except (IndexError, ValueError): + pass + return result + + +def collect_performance() -> dict[str, Any]: + """Collect performance data.""" + counts = _process_counts() + load1, _, _ = _load_average() + cores = os.cpu_count() or 1 + + return { + "processes_total": counts["total"], + "zombies": counts["zombies"], + "top_cpu_processes": _top_cpu_processes(), + "context_switches": _context_switches(), + "load_vs_cores_ratio": round(load1 / cores, 2), + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Section: Recent Issues +# ────────────────────────────────────────────────────────────────────────────── + + +def collect_issues() -> dict[str, Any]: + """Collect recent system issues from journals.""" + out, _ = run( + ["journalctl", "-p", "err", "-n", "10", "--no-pager"], + timeout=10, + ) + recent_errors = [ + line.strip() + for line in out.strip().split("\n") + if line.strip() and "-- No entries --" not in line + ][:10] + + out, _ = run( + ["journalctl", "-k", "--no-pager", "--grep", "Oops|BUG", "--output", "short"], + timeout=10, + ) + kernel_oops = [ + line.strip() + for line in out.strip().split("\n") + if line.strip() and "No entries" not in line + ][:5] + + # Also check kernel panic + if not kernel_oops: + out2, _ = run( + ["journalctl", "-k", "--no-pager", "--grep", "panic", "--output", "short"], + timeout=10, + ) + kernel_oops = [ + line.strip() + for line in out2.strip().split("\n") + if line.strip() and "No entries" not in line + ][:5] + + out, _ = run( + ["journalctl", "-k", "--no-pager", "--grep", "Out of memory", "--output", "short"], + timeout=10, + ) + oom_events = [ + line.strip() + for line in out.strip().split("\n") + if line.strip() and "No entries" not in line + ][-5:] + + return { + "recent_errors": recent_errors, + "kernel_oops": kernel_oops, + "oom_events": oom_events, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Health Score +# ────────────────────────────────────────────────────────────────────────────── + + +def compute_health(data: dict[str, Any]) -> dict[str, Any]: + """Compute a 0-100 health score with letter grade. + + Each factor is worth a fixed maximum: CPU 10, memory 10, disk 15, + SELinux 10, failed services 15, load 10, zombies 10, swap 10, + firewall 10 (total 100). Factors whose data source is unavailable — + section not collected, or the required tool not installed — are + excluded and the score is renormalised against the remaining + maximum, so missing tooling never looks like a security failure. + An explicitly disabled control (e.g. SELinux "Disabled") still + scores 0. + + Letter mapping: A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F < 60. + """ + earned = 0 + possible = 0 + breakdown: dict[str, int] = {} + maxima: dict[str, int] = {} + skipped: list[str] = [] + + def _factor(name: str, points: int | None, maximum: int) -> None: + """Record one factor; ``points=None`` means 'not assessable'.""" + nonlocal earned, possible + if points is None: + skipped.append(name) + return + breakdown[name] = points + maxima[name] = maximum + earned += points + possible += maximum + + cpu_pct = data.get("cpu", {}).get("usage_pct") + cpu_points = None + if cpu_pct is not None: + cpu_points = 10 if cpu_pct < 80 else 8 if cpu_pct < 90 else 4 if cpu_pct < 95 else 0 + _factor("cpu_usage", cpu_points, 10) + + mem_pct = data.get("memory", {}).get("usage_pct") + mem_points = None + if mem_pct is not None: + mem_points = 10 if mem_pct < 85 else 8 if mem_pct < 90 else 4 if mem_pct < 95 else 0 + _factor("memory_usage", mem_points, 10) + + critical = data.get("disk", {}).get("critical_fs") + disk_points = None + if critical is not None: + disk_points = 15 if not critical else 10 if len(critical) == 1 else 0 + _factor("disk_usage", disk_points, 15) + + # "unknown" means getenforce is not installed — not assessable. + selinux = data.get("security", {}).get("selinux", "unknown") + selinux_points = None + if selinux != "unknown": + selinux_points = 10 if selinux == "Enforcing" else 5 if selinux == "Permissive" else 0 + _factor("selinux", selinux_points, 10) + + # None means systemctl is not installed — not assessable. + failed = data.get("services", {}).get("failed_units") + svc_points = None + if failed is not None: + svc_points = 15 if not failed else 10 if len(failed) <= 2 else 5 if len(failed) <= 5 else 0 + _factor("services", svc_points, 15) + + load1 = data.get("overview", {}).get("load_1min") + cores = data.get("overview", {}).get("cpu_cores", 1) or 1 + load_points = None + if load1 is not None: + load_points = 10 if load1 < cores else 5 if load1 < cores * 2 else 0 + _factor("load", load_points, 10) + + zombies = data.get("performance", {}).get("zombies") + zombie_points = None + if zombies is not None: + zombie_points = 10 if zombies == 0 else 5 if zombies <= 3 else 0 + _factor("zombies", zombie_points, 10) + + swap_pct = data.get("memory", {}).get("swap_usage_pct") + swap_points = None + if swap_pct is not None: + swap_points = 10 if swap_pct < 50 else 5 if swap_pct < 75 else 0 + _factor("swap", swap_points, 10) + + # available=False means firewall-cmd is not installed — not assessable. + fw = data.get("security", {}).get("firewalld", {}) + fw_points = None + if fw.get("available"): + fw_points = 10 if fw.get("active") else 0 + _factor("firewall", fw_points, 10) + + score = round(100 * earned / possible) if possible > 0 else 0 + + if score >= 90: + letter = "A" + elif score >= 80: + letter = "B" + elif score >= 70: + letter = "C" + elif score >= 60: + letter = "D" + else: + letter = "F" + + return { + "score": score, + "max_score": 100, + "grade": letter, + "breakdown": breakdown, + "maxima": maxima, + "factors_skipped": skipped, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Human-readable report +# ────────────────────────────────────────────────────────────────────────────── + + +def _section_header(title: str) -> None: + """Print a section divider.""" + print(f"\n{BOLD}── {title} ──{RESET}") + + +def _kv(label: str, value: str, colour: str = "") -> None: + """Print a key-value line, optionally wrapped in a colour.""" + if colour: + print(f" {label:<22s} {colour}{value}{RESET}") + else: + print(f" {label:<22s} {value}") + + +def _kv_status(label: str, ok: bool, ok_text: str = "yes", fail_text: str = "no") -> None: + """Print a key-value line with colour-coded boolean status.""" + if ok: + print(f" {label:<22s} {GREEN}{ok_text}{RESET}") + else: + print(f" {label:<22s} {RED}{fail_text}{RESET}") + + +def print_report(data: dict[str, Any]) -> None: + """Render the human-readable diagnostic report to stdout.""" + health = data.get("health", {}) + grade_letter = str(health.get("grade", "?")) + grade_colour = GRADE_COLOURS.get(grade_letter, RESET) + + # Present sections in canonical order; numbers adapt to --section subsets. + order = [name for name in ALL_SECTIONS if name in data] + + # --- Header --- + print() + print(f"{BOLD}{'═' * WIDTH}{RESET}") + overview = data.get("overview", {}) + print(f" {BOLD}System Diagnostics v{VERSION}{RESET}") + print(f"{BOLD}{'═' * WIDTH}{RESET}") + print(f" Hostname: {overview.get('hostname', 'unknown')}") + print(f" Date: {time.strftime('%Y-%m-%d %H:%M:%S')}") + print( + f" Health: {grade_colour}{BOLD}{grade_letter}{RESET} " + f"({health.get('score', '?')}/{health.get('max_score', 100)})" + ) + + # --- System Overview --- + if "overview" in data: + _section_header(f"{order.index('overview') + 1}. System Overview") + o = data["overview"] + _kv("OS", str(o.get("os", "unknown"))) + _kv("Kernel", str(o.get("kernel", "unknown"))) + _kv("Uptime", str(o.get("uptime_human", "unknown"))) + _kv("Boot time", str(o.get("boot_time", "unknown"))) + + load1 = o.get("load_1min", 0) + load5 = o.get("load_5min", 0) + load15 = o.get("load_15min", 0) + cores = o.get("cpu_cores", 1) + load_col = GREEN if load1 < cores else YELLOW if load1 < cores * 2 else RED + _kv("Load avg (1/5/15m)", f"{load_col}{load1:.2f} / {load5:.2f} / {load15:.2f}{RESET}") + _kv("Users logged in", str(o.get("users_logged", 0))) + + # --- CPU --- + if "cpu" in data: + _section_header(f"{order.index('cpu') + 1}. CPU") + c = data["cpu"] + phys = c.get("physical_cores", 0) + threads = c.get("threads", 0) + _kv("Model", str(c.get("model", "unknown"))) + _kv("Architecture", str(c.get("architecture", "unknown"))) + _kv("Cores / Threads", f"{phys} physical / {threads} logical") + _kv("Frequency", str(c.get("frequency", "unknown"))) + _kv("Temperature", str(c.get("temperature", "unknown"))) + usage = c.get("usage_pct", 0.0) + usage_col = GREEN if usage < 80 else YELLOW if usage < 90 else RED + _kv("CPU usage", f"{usage_col}{usage}%{RESET} {_bar(usage)}") + + per_core = c.get("per_core", []) + if per_core: + print(f" {BOLD}Per-core usage:{RESET}") + for core in per_core: + core_usage = core["usage_pct"] + core_col = GREEN if core_usage < 80 else YELLOW if core_usage < 90 else RED + print( + f" cpu{core['cpu']:<4s} {core_col}{core_usage:>5.1f}%{RESET} " + f"{_bar(core_usage)}" + ) + + # --- Memory --- + if "memory" in data: + _section_header(f"{order.index('memory') + 1}. Memory") + m = data["memory"] + _kv("Total", f"{m.get('total_mb', 0)} MiB") + _kv("Used", f"{m.get('used_mb', 0)} MiB {_bar(m.get('usage_pct', 0))}") + _kv("Available", f"{m.get('available_mb', 0)} MiB") + _kv("Buffers", f"{m.get('buffers_mb', 0)} MiB") + _kv("Cached", f"{m.get('cached_mb', 0)} MiB") + + swap_total = m.get("swap_total_mb", 0) + swap_used = m.get("swap_used_mb", 0) + swap_pct = m.get("swap_usage_pct", 0) + swap_col = GREEN if swap_pct < 50 else YELLOW if swap_pct < 75 else RED + _kv("Swap", f"{swap_col}{swap_used} / {swap_total} MiB{RESET} {_bar(swap_pct)}") + + top_mem = m.get("top_processes", []) + if top_mem: + print(f"\n {BOLD}Top 5 memory consumers:{RESET}") + print(f" {'PID':<8s} {'USER':<10s} {'RSS':>8s} COMMAND") + for proc in top_mem: + print( + f" {proc['pid']:<8d} {proc['user']:<10s} " + f"{proc['rss_mb']:>6.1f}M {proc['command']}" + ) + + # --- Disk --- + if "disk" in data: + _section_header(f"{order.index('disk') + 1}. Disk") + d = data["disk"] + _kv("Total space", f"{d.get('total_gb', 0):.1f} GiB") + _kv("Used", f"{d.get('used_gb', 0):.1f} GiB {_bar(d.get('usage_pct', 0))}") + + fss = d.get("filesystems", []) + if fss: + print(f"\n {BOLD}Filesystems:{RESET}") + header = f" {'Device':<20s} {'Mount':<18s} {'Size':>7s} {'Used':>7s} {'Use%'}" + print(header) + print(f" {'-' * (len(header) - 2)}") + for fs in fss: + use_pct = fs["use_pct"] + pct_col = GREEN if use_pct < 80 else YELLOW if use_pct < 90 else RED + dev = fs["device"][:19] if len(fs["device"]) > 19 else fs["device"] + mnt = fs["mountpoint"][:17] if len(fs["mountpoint"]) > 17 else fs["mountpoint"] + print( + f" {dev:<20s} {mnt:<18s} {fs['size_gb']:>6.0f}G " + f"{fs['used_gb']:>6.0f}G {pct_col}{use_pct:>4.1f}%{RESET}" + ) + + if not fss and d.get("container"): + print(f" {DIM}(container detected — overlay root filesystem not shown){RESET}") + + critical = d.get("critical_fs", []) + if critical: + print(f"\n {RED}⚠ Warning: {len(critical)} filesystem(s) above 90% usage!{RESET}") + + io_stats = d.get("io_stats") + if io_stats: + print(f"\n {BOLD}I/O Stats (iostat):{RESET}") + for dev in io_stats.get("devices", []): + print( + f" {dev['device']:<10s} tps: {dev['tps']:>8.1f} " + f"read: {dev['read_mb_s']:>8.2f} MB/s " + f"write: {dev['write_mb_s']:>8.2f} MB/s" + ) + + # --- Network --- + if "network" in data: + _section_header(f"{order.index('network') + 1}. Network") + n = data["network"] + + ifaces = n.get("interfaces", []) + for iface in ifaces: + state_col = GREEN if iface["state"] == "UP" else RED + v6 = ", ".join(iface["ipv6"]) if iface["ipv6"] else f"{DIM}(none){RESET}" + v4 = ", ".join(iface["ipv4"]) if iface["ipv4"] else f"{DIM}(none){RESET}" + print( + f" {iface['name']:<8s} {state_col}{iface['state']:<5s}{RESET} " + f"speed: {iface.get('speed', 'unknown')}" + ) + print(f" IPv6: {v6}") + print(f" IPv4: {v4}") + + conns = n.get("connections", {}) + if conns: + _kv("TCP established", str(conns.get("tcp_established", "?"))) + _kv("Total connections", str(conns.get("total", "?"))) + + routes = n.get("routes", {}) + if routes.get("v6"): + _kv("Default IPv6 route", str(routes["v6"])) + if routes.get("v4"): + _kv("Default IPv4 route", str(routes["v4"])) + + ext_ip = n.get("external_ip", {}) + if ext_ip.get("v6", "unknown") != "unknown": + _kv("External IPv6", str(ext_ip["v6"])) + if ext_ip.get("v4", "unknown") != "unknown": + _kv("External IPv4", str(ext_ip["v4"])) + + # --- GPU --- + if "gpu" in data: + _section_header(f"{order.index('gpu') + 1}. GPU") + g = data["gpu"] + if g and g.get("unavailable"): + print(f" {DIM}GPU detection unavailable (no nvidia-smi/rocm-smi/lspci){RESET}") + elif g: + if g.get("fallback"): + print(f" {YELLOW}(hardware detected, GPU drivers not loaded){RESET}") + print(f" Vendor: {g.get('vendor', 'unknown')}") + for card in g.get("cards", []): + for k, v in card.items(): + _kv(k.capitalize(), str(v)) + vram = g.get("vram") + if vram: + total = vram.get("total_mb", 0) + used = vram.get("used_mb", 0) + free = vram.get("free_mb", 0) + pct = (used / total * 100) if total > 0 else 0 + colour = GREEN if pct < 80 else YELLOW if pct < 90 else RED + _kv("VRAM Used/Total", f"{colour}{used}/{total} MiB{RESET} ({pct:.0f}%)") + _kv("VRAM Free", f"{free} MiB") + else: + print(f" {DIM}No GPU detected{RESET}") + + # --- Services --- + if "services" in data: + _section_header(f"{order.index('services') + 1}. Services") + s = data["services"] + + failed = s.get("failed_units") + if failed is None: + print(f" {DIM}Failed units: unavailable (systemctl not installed){RESET}") + elif failed: + print(f" {RED}Failed units: {len(failed)}{RESET}") + for unit in failed: + print(f" {RED}✗{RESET} {unit['unit']} ({unit['state']})") + else: + print(f" {GREEN}No failed units{RESET}") + + print() + ks = s.get("key_services", {}) + for name, info in sorted(ks.items()): + status = info.get("status", "unknown") + if status == "active": + print(f" {GREEN}●{RESET} {name:<15s} {GREEN}{status}{RESET}") + elif status == "inactive": + print(f" {YELLOW}○{RESET} {name:<15s} {YELLOW}{status}{RESET}") + elif status == "failed": + print(f" {RED}✗{RESET} {name:<15s} {RED}{status}{RESET}") + else: + print(f" {DIM}─{RESET} {name:<15s} {DIM}{status}{RESET}") + + # --- Security --- + if "security" in data: + _section_header(f"{order.index('security') + 1}. Security") + sc = data["security"] + + selinux = sc.get("selinux", "unknown") + if selinux == "Enforcing": + sel_col = GREEN + elif selinux == "Permissive": + sel_col = YELLOW + elif selinux == "Disabled": + sel_col = RED + else: # "unknown" — SELinux tooling not installed + sel_col = DIM + _kv("SELinux", f"{sel_col}{selinux}{RESET}") + + fw = sc.get("firewalld", {}) + if fw.get("available"): + _kv_status("Firewalld active", bool(fw.get("active")), "active", "inactive") + if fw.get("default_zone"): + _kv("Default zone", str(fw["default_zone"])) + if fw.get("zones"): + _kv("Active zones", ", ".join(fw["zones"])) + else: + _kv("Firewalld", f"{DIM}not installed{RESET}") + + _kv("Failed SSH (today)", str(sc.get("failed_ssh_attempts", 0))) + _kv("Open TCP ports", str(sc.get("open_ports_count", 0))) + + logins = sc.get("last_logins", []) + if logins: + print(f"\n {BOLD}Last logins:{RESET}") + for login in logins: + print(f" {login}") + + # --- Performance --- + if "performance" in data: + _section_header(f"{order.index('performance') + 1}. Performance") + p = data["performance"] + + _kv("Processes total", str(p.get("processes_total", 0))) + zombies = p.get("zombies", 0) + zombie_col = GREEN if zombies == 0 else RED + _kv("Zombie processes", f"{zombie_col}{zombies}{RESET}") + + ctxt = p.get("context_switches", {}) + _kv("Ctxt switches (boot)", str(ctxt.get("ctxt", "?"))) + _kv("Interrupts (boot)", str(ctxt.get("intr", "?"))) + + load_ratio = p.get("load_vs_cores_ratio", 0) + ratio_col = GREEN if load_ratio < 1 else YELLOW if load_ratio < 2 else RED + _kv("Load / cores ratio", f"{ratio_col}{load_ratio:.2f}{RESET}") + + top_cpu = p.get("top_cpu_processes", []) + if top_cpu: + print(f"\n {BOLD}Top 5 CPU consumers (lifetime average):{RESET}") + print(f" {'PID':<8s} {'USER':<10s} {'CPU%':>6s} COMMAND") + for proc in top_cpu: + print( + f" {proc['pid']:<8d} {proc['user']:<10s} " + f"{proc['cpu_pct']:>5.1f}% {proc['command']}" + ) + + # --- Recent Issues --- + if "issues" in data: + _section_header(f"{order.index('issues') + 1}. Recent Issues") + i = data["issues"] + + errors = i.get("recent_errors", []) + if errors: + print(f" {BOLD}Last {len(errors)} errors from journal:{RESET}") + for line in errors: + # Truncate long lines + display = line[:120] + "…" if len(line) > 120 else line + print(f" {DIM}{display}{RESET}") + else: + print(f" {GREEN}No recent errors in journal{RESET}") + + oops = i.get("kernel_oops", []) + if oops: + print(f"\n {RED}⚠ Kernel OOPS / panic messages:{RESET}") + for line in oops: + display = line[:120] + "…" if len(line) > 120 else line + print(f" {RED}{display}{RESET}") + else: + print(f"\n {GREEN}No kernel OOPS detected{RESET}") + + oom = i.get("oom_events", []) + if oom: + print(f"\n {RED}⚠ OOM events:{RESET}") + for line in oom: + display = line[:120] + "…" if len(line) > 120 else line + print(f" {RED}{display}{RESET}") + else: + print(f"\n {GREEN}No OOM events in kernel log{RESET}") + + # --- Health Score --- + _section_header(f"{len(order) + 1}. Health Score") + bd = health.get("breakdown", {}) + labels = { + "cpu_usage": "CPU usage < 80%", + "memory_usage": "Memory usage < 85%", + "disk_usage": "No disk > 90%", + "selinux": "SELinux enforcing", + "services": "No failed services", + "load": "Load < cores", + "zombies": "No zombie processes", + "swap": "Swap usage < 50%", + "firewall": "Firewall active", + } + maxima = health.get("maxima", {}) + for key, label in labels.items(): + if key not in bd: + continue # factor could not be assessed in this run + pts = bd[key] + max_pts = maxima.get(key, 10) + bar = "█" * max(0, pts) + "░" * (max_pts - max(0, pts)) + print(f" {label:<24s} {bar} {pts}/{max_pts}") + skipped = health.get("factors_skipped", []) + if skipped: + names = ", ".join(labels.get(key, key) for key in skipped) + print(f" {DIM}Not assessed (data unavailable — score renormalised):{RESET}") + print(f" {DIM}{names}{RESET}") + + # --- Footer --- + print() + print(f"{BOLD}{'═' * WIDTH}{RESET}") + print( + f" {BOLD}Health Score: {grade_colour}{grade_letter}{RESET} " + f"({health.get('score', '?')}/{health.get('max_score', 100)})" + ) + print(f"{BOLD}{'═' * WIDTH}{RESET}") + print() + + +# ────────────────────────────────────────────────────────────────────────────── +# Data collection orchestrator +# ────────────────────────────────────────────────────────────────────────────── + + +def collect_all(sections: list[str], external_ip: bool = False) -> dict[str, Any]: + """Collect all diagnostics data, optionally filtered by section.""" + data: dict[str, Any] = { + "version": VERSION, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + } + + collectors: dict[str, tuple[Callable[[], Any], str]] = { + "overview": (collect_overview, "system overview"), + "cpu": (collect_cpu, "CPU"), + "memory": (collect_memory, "memory"), + "disk": (collect_disk, "disk"), + "network": (lambda: collect_network(include_external=external_ip), "network"), + "gpu": (collect_gpu, "GPU"), + "services": (collect_services, "services"), + "security": (collect_security, "security"), + "performance": (collect_performance, "performance"), + "issues": (collect_issues, "recent issues"), + } + + with ThreadPoolExecutor(max_workers=6) as executor: + future_map: dict[Future[Any], str] = { + executor.submit(collectors[section][0]): section + for section in dict.fromkeys(sections) + if section in collectors + } + + for future in as_completed(future_map): + section = future_map[future] + label = collectors[section][1] + _status(f"Collecting {label}") + try: + result = future.result() + data[section] = result if result is not None else {} + _status_done() + except Exception as exc: + data[section] = {"error": str(exc)} + _status_done("error") + print(f" [{label}] {exc}", file=sys.stderr) + + return data + + +def main() -> None: + """Parse arguments, collect diagnostics, and print the report.""" + parser = argparse.ArgumentParser( + description=( + "System diagnostics — CPU, memory, disk, network, GPU, services, security, performance" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--section", + default="", + help=( + f"Comma-separated sections to run. Available: {', '.join(ALL_SECTIONS)} [default: all]" + ), + ) + parser.add_argument( + "--external-ip", + action="store_true", + help=( + "Also detect external IP addresses via icanhazip.com " + "(sends a request to a third-party service)" + ), + ) + parser.add_argument( + "--json", + action="store_true", + help="Output machine-readable JSON to stdout", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION}", + ) + args = parser.parse_args() + + if sys.platform != "linux": + print( + f"{RED}Error: system-diag.py currently supports Linux only " + f"(detected platform: {sys.platform}).{RESET}", + file=sys.stderr, + ) + sys.exit(1) + + # Determine which sections to run + if args.section: + sections = [s.strip().lower() for s in args.section.split(",")] + invalid = [s for s in sections if s not in ALL_SECTIONS] + if invalid: + print( + f"{RED}Error: unknown section(s): {', '.join(invalid)}{RESET}", + file=sys.stderr, + ) + print( + f"Available: {', '.join(ALL_SECTIONS)}", + file=sys.stderr, + ) + sys.exit(1) + else: + sections = list(ALL_SECTIONS) + + # Collect data + data = collect_all(sections, external_ip=args.external_ip) + + # Compute health score + health = compute_health(data) + data["health"] = health + + # Output + if args.json: + print(json.dumps(data, indent=2, default=str)) + else: + print_report(data) + + +if __name__ == "__main__": + main() diff --git a/system-optimise.py b/system-optimise.py new file mode 100644 index 0000000..263feda --- /dev/null +++ b/system-optimise.py @@ -0,0 +1,1251 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +# SPDX-License-Identifier: MIT + +"""Idempotent system cleanup and optimisation for Fedora and openEuler. + +Cleans old kernels, DNF caches, systemd journals, temp files, and core dumps. +Every operation checks current state before acting; running the script +repeatedly produces the same result with no errors and no duplicate work. +ostree-based (atomic) systems are refused — rpm-ostree manages those. + +Usage: + system-optimise.py # full cleanup + system-optimise.py --dry-run # preview without changes + system-optimise.py --skip-dnf # skip DNF cleanup + system-optimise.py --skip-journal # skip journal cleanup + system-optimise.py --skip-tmp # skip temp file cleanup + system-optimise.py --skip-cores # skip core dump cleanup + system-optimise.py --version +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +from functools import cmp_to_key +from typing import Any, Callable + +BOLD = "\033[1m" +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +DIM = "\033[2m" +RESET = "\033[0m" +VERSION = "1.3.1" + +SUPPORTED_OS: dict[str, str] = { + "fedora": "Fedora", + "openeuler": "openEuler", +} + +# Kernel retention: keep the newest INSTALLONLY_LIMIT kernels plus the running +# one. dnf forbids installonly_limit=1 — a bootable fallback must always remain. +INSTALLONLY_LIMIT = 2 + +# Temp file retention (matches systemd-tmpfiles defaults on both targets). +TMP_MAX_AGE_DAYS = 10 +VARTMP_MAX_AGE_DAYS = 30 + +# Core dumps younger than this are kept: a crash under active analysis must +# never be swept away by a cleanup run. +COREDUMP_MAX_AGE_DAYS = 7 + +JOURNAL_VACUUM_SIZE_MB = 500 +JOURNAL_WARN_BYTES = 1024 * 1024 * 1024 # warn when the journal stays above 1 GiB +JOURNAL_DIRS = ("/var/log/journal", "/run/log/journal") + +# Unit file types audited for leftovers in /etc/systemd/system/. +UNIT_SUFFIXES = ( + ".service", + ".socket", + ".timer", + ".target", + ".path", + ".mount", + ".automount", + ".slice", +) + + +# --------------------------------------------------------------------------- +# 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 = 120) -> subprocess.CompletedProcess[str]: + """Execute a command and return the CompletedProcess. + + LC_ALL=C is forced for reliable English-language output parsing. + Missing binaries, timeouts, and exec failures are converted into a + synthetic failed CompletedProcess (rc 127 / 124 / 126) so callers only + ever need to check returncode — exceptions never escape this function. + """ + env = {**os.environ, "LANG": "C", "LC_ALL": "C"} + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env) + except FileNotFoundError: + return subprocess.CompletedProcess(cmd, 127, "", f"command not found: {cmd[0]}") + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout}s") + except OSError as exc: + return subprocess.CompletedProcess(cmd, 126, "", f"cannot execute {cmd[0]}: {exc}") + + +# --------------------------------------------------------------------------- +# 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") + + # ostree-based (atomic) systems report a normal ID (e.g. Silverblue is + # ID=fedora) but have no dnf and manage kernels via rpm-ostree — refuse. + if os.path.exists("/run/ostree-booted"): + print( + f"{RED}{BOLD}Error:{RESET} ostree-based (atomic) system detected " + "(/run/ostree-booted present).", + file=sys.stderr, + ) + print( + " Package and kernel management there belongs to rpm-ostree; " + "this script only supports traditional dnf/rpm systems.", + file=sys.stderr, + ) + sys.exit(1) + + 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 system-optimise.py", + file=sys.stderr, + ) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Utility helpers +# --------------------------------------------------------------------------- + + +def _format_size(size_bytes: int) -> str: + """Format a byte count into a human-readable string (MB or GB).""" + if size_bytes < 0: + return "0 B" + if size_bytes >= 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + if size_bytes >= 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + if size_bytes >= 1024: + return f"{size_bytes / 1024:.1f} KB" + return f"{size_bytes} B" + + +def _du(path: str) -> int: + """Return the total size (in bytes) of all files under *path*. + + Returns 0 if the path does not exist. + """ + if not os.path.exists(path): + return 0 + total: int = 0 + for dirpath, _dirnames, filenames in os.walk(path): + for fname in filenames: + fpath = os.path.join(dirpath, fname) + try: + total += os.path.getsize(fpath) + except OSError: + pass + return total + + +def _last_lines(text: str, count: int = 3) -> str: + """Return the last *count* non-empty lines of *text*, joined for display.""" + lines = [line.strip() for line in text.strip().split("\n") if line.strip()] + return " | ".join(lines[-count:]) if lines else "no error output" + + +# --------------------------------------------------------------------------- +# Section 1 — DNF Cleanup +# --------------------------------------------------------------------------- + + +def _kernel_pkg_name(os_id: str) -> str: + """Return the installonly kernel package name for the detected OS.""" + return "kernel" if os_id == "openeuler" else "kernel-core" + + +def _rpmvercmp(ver_a: str, ver_b: str) -> int: + """Compare two version-release strings using RPM's rpmvercmp algorithm. + + Segments alternate between digits and letters; numeric segments compare + numerically and always beat alphabetic ones; "~" sorts before everything + (including end-of-string). Returns <0, 0 or >0 like strcmp. + """ + pos_a = pos_b = 0 + while pos_a < len(ver_a) or pos_b < len(ver_b): + tilde_a = pos_a < len(ver_a) and ver_a[pos_a] == "~" + tilde_b = pos_b < len(ver_b) and ver_b[pos_b] == "~" + if tilde_a or tilde_b: + if tilde_a and tilde_b: + pos_a += 1 + pos_b += 1 + continue + return -1 if tilde_a else 1 + # skip separators (anything not alphanumeric) + while pos_a < len(ver_a) and not ver_a[pos_a].isalnum(): + pos_a += 1 + while pos_b < len(ver_b) and not ver_b[pos_b].isalnum(): + pos_b += 1 + if pos_a >= len(ver_a) or pos_b >= len(ver_b): + break + if ver_a[pos_a].isdigit() != ver_b[pos_b].isdigit(): + # numeric segments always beat alphabetic ones + return 1 if ver_a[pos_a].isdigit() else -1 + start_a, start_b = pos_a, pos_b + if ver_a[pos_a].isdigit(): + while pos_a < len(ver_a) and ver_a[pos_a].isdigit(): + pos_a += 1 + while pos_b < len(ver_b) and ver_b[pos_b].isdigit(): + pos_b += 1 + seg_a = ver_a[start_a:pos_a].lstrip("0") or "0" + seg_b = ver_b[start_b:pos_b].lstrip("0") or "0" + if len(seg_a) != len(seg_b): + return 1 if len(seg_a) > len(seg_b) else -1 + else: + while pos_a < len(ver_a) and ver_a[pos_a].isalpha(): + pos_a += 1 + while pos_b < len(ver_b) and ver_b[pos_b].isalpha(): + pos_b += 1 + seg_a = ver_a[start_a:pos_a] + seg_b = ver_b[start_b:pos_b] + if seg_a != seg_b: + return 1 if seg_a > seg_b else -1 + # whichever side still has segments left over is the newer version + if pos_a < len(ver_a): + return 1 + if pos_b < len(ver_b): + return -1 + return 0 + + +def _rpm_evr_cmp(evr_a: str, evr_b: str) -> int: + """Compare two EVR (epoch:version-release) strings, epoch first.""" + epoch_a, _, rest_a = evr_a.partition(":") + epoch_b, _, rest_b = evr_b.partition(":") + if epoch_a.isdigit() and epoch_b.isdigit() and epoch_a != epoch_b: + return 1 if int(epoch_a) > int(epoch_b) else -1 + return _rpmvercmp(rest_a, rest_b) + + +def _list_kernel_packages(os_id: str) -> list[str] | None: + """List installed kernel package EVRs (epoch:version-release.arch), newest first. + + Returns None when the rpm query itself fails — callers must distinguish + that from an empty list (no kernel packages installed). Sorting uses RPM + version comparison: rpm -q output order is install order and must never + be trusted as version order. + """ + result = run( + [ + "rpm", + "-q", + _kernel_pkg_name(os_id), + "--qf", + "%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n", + ], + timeout=30, + ) + if result.returncode != 0: + return None + evrs = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()] + evrs.sort(key=cmp_to_key(_rpm_evr_cmp), reverse=True) + return evrs + + +def _select_kernels_to_keep(kernels: list[str], running: str) -> tuple[list[str], list[str]]: + """Split EVRs (newest first) into (kept, would_remove). + + Mirrors dnf's --oldinstallonly behaviour with INSTALLONLY_LIMIT: keep the + newest INSTALLONLY_LIMIT versions and always keep the running kernel, + even when it is not among the newest (updated but not yet rebooted). + """ + kept = list(kernels[:INSTALLONLY_LIMIT]) + running_evr = next((e for e in kernels if e.split(":", 1)[1] == running), None) + if running_evr is not None and running_evr not in kept: + kept.append(running_evr) + would_remove = [e for e in kernels if e not in kept] + return kept, would_remove + + +def _dnf_generation() -> int: + """Return the dnf major version (e.g. 4 or 5); defaults to 4 when undetectable.""" + p = run(["dnf", "--version"], timeout=15) + if p.returncode == 0 and p.stdout.strip(): + match = re.search(r"(\d+)\.\d+", p.stdout.strip().splitlines()[0]) + if match: + return int(match.group(1)) + return 4 + + +def _dnf_cache_dirs(dnf_major: int) -> tuple[list[str], list[str]]: + """Return (active, stale) dnf cache directories for the dnf generation. + + dnf5 caches in /var/cache/libdnf5; dnf4 in /var/cache/dnf (+ legacy yum). + Stale dirs belong to the other generation — reported, never auto-cleaned. + """ + if dnf_major >= 5: + return ["/var/cache/libdnf5"], ["/var/cache/dnf", "/var/cache/yum"] + return ["/var/cache/dnf", "/var/cache/yum"], [] + + +def _parse_removal_count(text: str) -> int | None: + """Extract the removed-package count from dnf transaction output. + + Handles dnf4 ("Remove 12 Packages") and dnf5 ("Removing: 12 packages") + summary wording. Returns 0 for "Nothing to do", None when unparseable. + """ + match = re.search(r"^Remove\s+(\d+)\s+Packages?\b", text, re.MULTILINE) + if match is None: + match = re.search(r"^\s*Removing:\s+(\d+)\s+packages?\b", text, re.MULTILINE) + if match: + return int(match.group(1)) + return 0 if "Nothing to do" in text else None + + +def dnf_cleanup( + os_id: str, dnf_major: int, *, dry_run: bool, warnings: list[str] +) -> dict[str, Any]: + """Prune old kernels via dnf's own mechanism, autoremove, and clean the cache. + + Kernel pruning delegates to ``dnf remove --oldinstallonly``, which keeps + the newest INSTALLONLY_LIMIT versions and never touches the running + kernel. Returns a dict with keys: autoremoved, autoremoved_count, + kernels_removed (real mode) / kernels_planned (dry-run), kernels_kept, + space_saved, cache_cleaned, errors. + """ + pkg_name = _kernel_pkg_name(os_id) + result: dict[str, Any] = { + "autoremoved": False, + "autoremoved_count": 0, + "kernels_removed": 0, + "kernels_planned": 0, + "kernels_kept": "", + "space_saved": 0, + "cache_cleaned": False, + "errors": [], + } + + # 1. Autoremove unneeded packages + _status("Removing unneeded packages (dnf autoremove)") + if dry_run: + # --assumeno makes dnf print the transaction and answer "no"; the + # exit code may then be non-zero, so parse stdout before checking it. + p = run(["dnf", "autoremove", "--assumeno"], timeout=300) + count = _parse_removal_count(p.stdout) + if count is not None: + result["autoremoved_count"] = count + _status_done(f"would remove {count} package(s)" if count else "nothing to remove") + elif p.returncode == 0: + _status_done("would run (package count unavailable)") + else: + _status_done("failed") + _warn(f"dnf autoremove preview failed: {_last_lines(p.stderr)}") + result["errors"].append("dnf autoremove preview failed") + else: + p = run(["dnf", "autoremove", "-y"], timeout=300) + if p.returncode == 0: + count = _parse_removal_count(p.stdout) + result["autoremoved_count"] = count or 0 + result["autoremoved"] = bool(count) + _status_done(f"removed {count} package(s)" if count else "nothing to do") + else: + _status_done("failed") + _warn(f"dnf autoremove failed: {_last_lines(p.stderr)}") + result["errors"].append("dnf autoremove failed") + + # 2. Prune old kernels — dnf decides what to remove, never the script + kernels = _list_kernel_packages(os_id) + if kernels is None: + _warn("Could not query installed kernels (rpm) — skipping kernel cleanup") + warnings.append("Kernel query via rpm failed — kernel cleanup skipped") + result["errors"].append("rpm kernel query failed") + result["kernels_kept"] = "unknown" + elif not kernels: + _info("No kernel packages found") + result["kernels_kept"] = "none" + else: + running = os.uname().release + kept, would_remove = _select_kernels_to_keep(kernels, running) + result["kernels_kept"] = ", ".join(f"{pkg_name}-{evr}" for evr in kept) + if not would_remove: + _info(f"Old kernels: none to remove ({len(kernels)} installed, keeping {len(kept)})") + elif dry_run: + _status(f"Pruning old kernels (keeping newest {INSTALLONLY_LIMIT} + running)") + result["kernels_planned"] = len(would_remove) + _status_done(f"would remove {len(would_remove)}") + for evr in would_remove: + _info(f"would remove {pkg_name}-{evr}") + else: + _status(f"Pruning {len(would_remove)} old kernel(s) via dnf (keeping {len(kept)})") + cmd = ["dnf", "remove", "-y", "--oldinstallonly"] + if dnf_major >= 5: + cmd.append(f"--limit={INSTALLONLY_LIMIT}") + else: + cmd += ["--setopt", f"installonly_limit={INSTALLONLY_LIMIT}"] + cmd.append(pkg_name) + p = run(cmd, timeout=300) + if p.returncode == 0: + result["kernels_removed"] = len(would_remove) + _status_done(f"removed {len(would_remove)} (running: {running})") + else: + _status_done("failed") + _warn(f"Kernel removal failed: {_last_lines(p.stderr)}") + result["errors"].append("old kernel removal failed") + + # 3. Clean DNF cache — measure only what the active dnf generation cleans + active_dirs, stale_dirs = _dnf_cache_dirs(dnf_major) + for stale in stale_dirs: + stale_size = _du(stale) + if stale_size > 0: + msg = f"Stale dnf4 cache at {stale} ({_format_size(stale_size)}) — unused by dnf5" + _info(msg) + warnings.append(msg) + cache_before = sum(_du(d) for d in active_dirs) + _status("Cleaning DNF cache") + if dry_run: + result["space_saved"] += cache_before + _status_done(f"would free ~{_format_size(cache_before)}") + else: + p = run(["dnf", "clean", "all"], timeout=60) + if p.returncode == 0: + saved = max(0, cache_before - sum(_du(d) for d in active_dirs)) + result["space_saved"] += saved + result["cache_cleaned"] = True + _status_done(_format_size(saved)) + else: + _status_done("failed") + _warn(f"dnf clean all failed: {_last_lines(p.stderr)}") + result["errors"].append("dnf clean all failed") + + return result + + +# --------------------------------------------------------------------------- +# Section 2 — Journal Cleanup +# --------------------------------------------------------------------------- + + +def journal_cleanup(*, dry_run: bool, warnings: list[str]) -> dict[str, Any]: + """Vacuum the systemd journal to JOURNAL_VACUUM_SIZE_MB and report space freed. + + Measures both the persistent (/var/log/journal) and volatile + (/run/log/journal) locations. Returns a dict with keys: vacuumed, + space_saved, journal_size_after, persistent_warning, errors. + """ + result: dict[str, Any] = { + "vacuumed": False, + "space_saved": 0, + "journal_size_after": 0, + "persistent_warning": False, + "errors": [], + } + + def journal_size() -> int: + return sum(_du(path) for path in JOURNAL_DIRS if os.path.exists(path)) + + vacuum_arg = f"--vacuum-size={JOURNAL_VACUUM_SIZE_MB}M" + size_before = journal_size() + _status(f"Vacuuming journal ({vacuum_arg})") + if dry_run: + estimated = max(0, size_before - JOURNAL_VACUUM_SIZE_MB * 1024 * 1024) + result["space_saved"] = estimated + _status_done(f"would free ~{_format_size(estimated)}") + size_check = size_before - estimated + else: + p = run(["journalctl", vacuum_arg], timeout=120) + if p.returncode == 0: + result["vacuumed"] = True + result["journal_size_after"] = journal_size() + result["space_saved"] = max(0, size_before - result["journal_size_after"]) + _status_done(_format_size(result["space_saved"])) + else: + _status_done("failed") + _warn(f"journalctl vacuum failed: {_last_lines(p.stderr)}") + result["errors"].append("journal vacuum failed") + size_check = journal_size() + + # Warn when the journal stays large despite vacuuming (post-vacuum size) + if size_check > JOURNAL_WARN_BYTES: + msg = ( + f"Journal still large ({_format_size(size_check)}) after vacuum. " + "Consider setting SystemMaxUse= in /etc/systemd/journald.conf" + ) + _warn(msg) + warnings.append(msg) + result["persistent_warning"] = True + + return result + + +# --------------------------------------------------------------------------- +# Section 3 — systemd Audit +# --------------------------------------------------------------------------- + + +def systemd_audit(*, warnings: list[str]) -> dict[str, Any]: + """Audit systemd for failed units, disabled unit files, and leftover + files in /etc/systemd/system/. + + This section is purely informational — no changes are made. + + Returns a dict with keys: failed_units, disabled_count, leftover_files, + errors. + """ + result: dict[str, Any] = { + "failed_units": [], + "disabled_count": 0, + "leftover_files": [], + "errors": [], + } + + # 1. Failed units + _status("Checking for failed systemd units") + p = run(["systemctl", "--failed", "--no-legend"], timeout=30) + if p.returncode != 0 and not p.stdout.strip(): + _status_done("unavailable") + msg = f"systemctl --failed unavailable: {_last_lines(p.stderr)}" + _warn(msg) + warnings.append(msg) + else: + failed_lines = [line.strip() for line in p.stdout.strip().split("\n") if line.strip()] + result["failed_units"] = failed_lines + _status_done(f"{len(failed_lines)} failed") + for line in failed_lines: + _warn(f"Failed unit: {line}") + if failed_lines: + warnings.append(f"{len(failed_lines)} failed systemd unit(s) — see above") + + # 2. Disabled unit files (informational) + _status("Counting disabled unit files") + p = run( + ["systemctl", "list-unit-files", "--state=disabled", "--no-legend"], + timeout=30, + ) + disabled_lines = [line.strip() for line in p.stdout.strip().split("\n") if line.strip()] + result["disabled_count"] = len(disabled_lines) + _status_done(f"{result['disabled_count']} disabled (informational only)") + + # 3. Leftover unit files: regular files systemd does not know about. + # A single list-unit-files call yields every unit name systemd + # recognises — static, disabled and indirect units are legitimate and + # must NOT be flagged; only names missing entirely are leftovers. + etc_systemd = "/etc/systemd/system" + if os.path.isdir(etc_systemd): + _status("Checking for leftover unit files") + p = run(["systemctl", "list-unit-files", "--no-legend"], timeout=30) + if p.returncode != 0: + _status_done("unavailable") + msg = f"systemctl list-unit-files unavailable: {_last_lines(p.stderr)}" + _warn(msg) + warnings.append(msg) + else: + known = {line.split()[0] for line in p.stdout.splitlines() if line.split()} + leftovers = [] + for entry in sorted(os.listdir(etc_systemd)): + full_path = os.path.join(etc_systemd, entry) + if os.path.islink(full_path) or not os.path.isfile(full_path): + continue + if not entry.endswith(UNIT_SUFFIXES): + continue + if entry not in known: + leftovers.append(entry) + result["leftover_files"] = leftovers + if leftovers: + _status_done(f"{len(leftovers)} leftover(s)") + for leftover in leftovers: + _warn(f"Leftover unit file: {etc_systemd}/{leftover}") + warnings.append(f"{len(leftovers)} leftover unit file(s) in {etc_systemd}/") + else: + _status_done("none") + + return result + + +# --------------------------------------------------------------------------- +# Section 4 — Temp File Cleanup +# --------------------------------------------------------------------------- + + +def _find_old_files(directory: str, days: int) -> list[str] | None: + """List regular files under *directory* older than *days* days. + + Stays on one filesystem (-xdev) and never matches the start directory + itself (-mindepth 1). Returns None when the find command fails. + """ + p = run( + [ + "find", + directory, + "-mindepth", + "1", + "-xdev", + "-type", + "f", + "-mtime", + f"+{days}", + "-print0", + ], + timeout=120, + ) + if p.returncode != 0: + return None + return [path for path in p.stdout.split("\0") if path] + + +def _paths_size(paths: list[str]) -> int: + """Return the total size of *paths*, ignoring files that vanished.""" + total = 0 + for path in paths: + try: + total += os.path.getsize(path) + except OSError: + pass + return total + + +def _clean_temp_dir( + directory: str, + days: int, + *, + dry_run: bool, + warnings: list[str], + errors: list[str], +) -> tuple[int, int]: + """Clean one temp directory; return (files affected, bytes freed). + + Files are counted and measured BEFORE anything is deleted, so real-mode + reporting reflects what was actually removed. Dry-run deletes nothing and + reports the exact size of the matching files only. + """ + old_files = _find_old_files(directory, days) + if old_files is None: + _status_done("failed") + _warn(f"find {directory} failed — cannot list old files") + warnings.append(f"Temp cleanup of {directory} failed (find error)") + errors.append(f"find failed in {directory}") + return 0, 0 + old_size = _paths_size(old_files) + if dry_run: + _status_done(f"would remove {len(old_files)} file(s) (~{_format_size(old_size)})") + return len(old_files), old_size + + for extra_args in (["-type", "f", "-mtime", f"+{days}"], ["-type", "d", "-empty"]): + p = run( + ["find", directory, "-mindepth", "1", "-xdev", *extra_args, "-delete"], + timeout=120, + ) + if p.returncode != 0: + _warn(f"find {directory} -delete failed: {_last_lines(p.stderr)}") + warnings.append(f"Temp cleanup of {directory} hit find errors") + errors.append(f"find -delete failed in {directory}") + + remaining = _find_old_files(directory, days) or [] + removed = len(old_files) - len(remaining) + freed = max(0, old_size - _paths_size(remaining)) + _status_done(f"{removed} file(s), freed {_format_size(freed)}") + return removed, freed + + +def temp_cleanup(*, dry_run: bool, warnings: list[str]) -> dict[str, Any]: + """Remove old files from /tmp and /var/tmp. + + /tmp: files older than TMP_MAX_AGE_DAYS. /var/tmp: files older than + VARTMP_MAX_AGE_DAYS. Returns a dict with keys: tmp_files_removed, + vartmp_files_removed (real mode) / *_planned (dry-run), space_saved, + errors. + """ + result: dict[str, Any] = { + "tmp_files_removed": 0, + "vartmp_files_removed": 0, + "tmp_files_planned": 0, + "vartmp_files_planned": 0, + "space_saved": 0, + "errors": [], + } + + _status(f"Cleaning /tmp (files older than {TMP_MAX_AGE_DAYS} days)") + removed, freed = _clean_temp_dir( + "/tmp", TMP_MAX_AGE_DAYS, dry_run=dry_run, warnings=warnings, errors=result["errors"] + ) + result["tmp_files_planned" if dry_run else "tmp_files_removed"] = removed + result["space_saved"] += freed + + if os.path.isdir("/var/tmp"): + _status(f"Cleaning /var/tmp (files older than {VARTMP_MAX_AGE_DAYS} days)") + removed, freed = _clean_temp_dir( + "/var/tmp", + VARTMP_MAX_AGE_DAYS, + dry_run=dry_run, + warnings=warnings, + errors=result["errors"], + ) + result["vartmp_files_planned" if dry_run else "vartmp_files_removed"] = removed + result["space_saved"] += freed + else: + _info("/var/tmp: directory not found, skipping") + + return result + + +# --------------------------------------------------------------------------- +# Section 5 — Core Dump Cleanup +# --------------------------------------------------------------------------- + + +def coredump_cleanup(*, dry_run: bool, warnings: list[str]) -> dict[str, Any]: + """Remove core dumps older than COREDUMP_MAX_AGE_DAYS. + + Operates on /var/lib/systemd/coredump/. Recent dumps are always kept — + a crash under active analysis must never be deleted. Returns a dict with + keys: cores_removed (real mode) / cores_planned (dry-run), + cores_kept_recent, space_saved, errors. + """ + result: dict[str, Any] = { + "cores_removed": 0, + "cores_planned": 0, + "cores_kept_recent": 0, + "space_saved": 0, + "errors": [], + } + + coredump_dir = "/var/lib/systemd/coredump" + if not os.path.isdir(coredump_dir): + _info("No coredump directory found, skipping") + return result + + cutoff = time.time() - COREDUMP_MAX_AGE_DAYS * 86400 + old_dumps: list[tuple[str, float, int]] = [] # (name, mtime, size) + recent = 0 + try: + entries = sorted(os.listdir(coredump_dir)) + except OSError as exc: + _warn(f"Cannot list {coredump_dir}: {exc}") + warnings.append(f"Core dump cleanup skipped — cannot read {coredump_dir}") + result["errors"].append("coredump listdir failed") + return result + for entry in entries: + full_path = os.path.join(coredump_dir, entry) + try: + stat = os.stat(full_path) + except OSError: + continue + if not os.path.isfile(full_path): + continue + if stat.st_mtime < cutoff: + old_dumps.append((entry, stat.st_mtime, stat.st_size)) + else: + recent += 1 + result["cores_kept_recent"] = recent + + if not old_dumps: + _info(f"No core dumps older than {COREDUMP_MAX_AGE_DAYS} days found") + if recent: + _info(f"Keeping {recent} recent core dump(s) (< {COREDUMP_MAX_AGE_DAYS} days old)") + return result + + # List every dump with its date and size before any deletion happens. + total_size = sum(size for _, _, size in old_dumps) + heading = "Would remove" if dry_run else "Removing" + _info( + f"{heading} {len(old_dumps)} core dump(s) older than " + f"{COREDUMP_MAX_AGE_DAYS} days ({_format_size(total_size)}):" + ) + for name, mtime, size in old_dumps: + date = time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime)) + prefix = "would remove" if dry_run else "remove" + _info(f" {prefix}: {name} ({date}, {_format_size(size)})") + + if dry_run: + result["cores_planned"] = len(old_dumps) + result["space_saved"] = total_size + return result + + _status(f"Deleting {len(old_dumps)} core dump(s)") + removed = 0 + freed = 0 + failed = 0 + for name, _, size in old_dumps: + try: + os.unlink(os.path.join(coredump_dir, name)) + removed += 1 + freed += size + except OSError as exc: + failed += 1 + _warn(f"Could not remove {name}: {exc}") + result["cores_removed"] = removed + result["space_saved"] = max(0, freed) + if failed: + msg = f"{failed} core dump(s) could not be removed — see above" + warnings.append(msg) + result["errors"].append("coredump removal incomplete") + _status_done(f"removed {removed}, freed {_format_size(result['space_saved'])}") + if recent: + _info(f"Keeping {recent} recent core dump(s) (< {COREDUMP_MAX_AGE_DAYS} days old)") + return result + + +# --------------------------------------------------------------------------- +# Section 6 — Package Audit (informational only) +# --------------------------------------------------------------------------- + + +def package_audit(*, dnf_major: int, warnings: list[str]) -> dict[str, Any]: + """Audit dnf for packages not present in any repository. + + Informational only — no packages are removed. Uses the ``--extras`` + option form, which works on both dnf4 and dnf5 (the positional + "dnf list extras" is dnf4-only); on dnf5, JSON output avoids fragile + text parsing. Returns a dict with keys: extras_count, extras_packages, + errors. + """ + result: dict[str, Any] = { + "extras_count": 0, + "extras_packages": [], + "errors": [], + } + + _status("Checking for packages not in any repo (dnf list --extras)") + names: list[str] | None = None + if dnf_major >= 5: + p = run(["dnf", "list", "--extras", "--json"], timeout=60) + if p.returncode == 0: + try: + data = json.loads(p.stdout) + names = sorted( + f"{pkg['name']}.{pkg['arch']}" if pkg.get("arch") else pkg["name"] + for section in data.values() + if isinstance(section, list) + for pkg in section + if isinstance(pkg, dict) and pkg.get("name") + ) + except (json.JSONDecodeError, AttributeError, TypeError): + names = None + else: + p = run(["dnf", "list", "--extras", "--quiet"], timeout=60) + if p.returncode == 0: + headers = ( + "last metadata", + "extra packages", + "available packages", + "installed packages", + ) + names = [ + line.split()[0] + for line in p.stdout.strip().split("\n") + if line.strip() and not line.strip().lower().startswith(headers) + ] + + if names is None: + _status_done("unavailable") + detail = _last_lines(p.stderr) if p.returncode != 0 else "unparseable dnf output" + msg = f"dnf list --extras unavailable: {detail}" + _warn(msg) + warnings.append(msg) + return result + + result["extras_count"] = len(names) + result["extras_packages"] = names + if names: + _status_done(f"{len(names)} package(s)") + for name in names[:10]: + _warn(f"Extra package: {name}") + if len(names) > 10: + _warn(f" ... and {len(names) - 10} more") + warnings.append(f"{len(names)} installed package(s) not found in any repo") + else: + _status_done("none") + + return result + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def print_summary( + os_display: str, + version_id: str, + results: dict[str, Any], + warnings: list[str], + elapsed: float, + *, + dry_run: bool, +) -> None: + """Print a final summary of all sections to stderr. + + In dry-run mode everything is reported as "would …" — planned actions + are never stated as accomplished, and sizes are estimates marked "~". + """ + print(f"\n{BOLD}── Optimisation Summary ──{RESET}", file=sys.stderr) + print(f" OS: {os_display} {version_id}", file=sys.stderr) + if dry_run: + print( + f' {YELLOW}DRY RUN — nothing was changed; all actions are "would …"{RESET}', + file=sys.stderr, + ) + + # DNF + rd = results.get("dnf", {}) + if rd: + count = rd.get("autoremoved_count") or 0 + if dry_run: + if count > 0: + _info(f"DNF autoremove: would remove {count} package(s)") + else: + _info("DNF autoremove: nothing to remove") + elif rd.get("autoremoved"): + _ok(f"DNF autoremove: removed {count} package(s)") + else: + _info("DNF autoremove: nothing to remove") + kernels = rd.get("kernels_planned", 0) if dry_run else rd.get("kernels_removed", 0) + if kernels > 0: + if dry_run: + _info(f"Old kernels: would remove {kernels}") + else: + _ok(f"Old kernels removed: {kernels}") + else: + _info("Old kernels: none to remove") + _info(f"Kernels kept: {rd.get('kernels_kept', '?')}") + cache_saved = _format_size(rd.get("space_saved", 0)) + if dry_run: + _info(f"DNF cache: would clean (~{cache_saved})") + elif rd.get("cache_cleaned"): + _ok(f"DNF cache: cleaned (freed {cache_saved})") + + # Journal + rj = results.get("journal", {}) + if rj: + if dry_run: + _info( + f"Journal: would vacuum to {JOURNAL_VACUUM_SIZE_MB}M " + f"(est. freed ~{_format_size(rj.get('space_saved', 0))})" + ) + elif rj.get("vacuumed"): + _ok(f"Journal vacuumed: freed {_format_size(rj.get('space_saved', 0))}") + if rj.get("persistent_warning"): + _warn("Journal storage is still large — check SystemMaxUse= in journald.conf") + + # systemd audit (informational in both modes) + rs = results.get("systemd", {}) + if rs: + failed = len(rs.get("failed_units", [])) + if failed > 0: + _warn(f"Failed systemd units: {failed}") + else: + _ok("Failed systemd units: none") + _info(f"Disabled unit files: {rs.get('disabled_count', 0)} (informational)") + leftovers = len(rs.get("leftover_files", [])) + if leftovers > 0: + _warn(f"Leftover unit files in /etc/systemd/system/: {leftovers}") + else: + _info("Leftover unit files: none") + + # Temp cleanup + rt = results.get("tmp", {}) + if rt: + if dry_run: + tmp_count = rt.get("tmp_files_planned", 0) + vartmp_count = rt.get("vartmp_files_planned", 0) + else: + tmp_count = rt.get("tmp_files_removed", 0) + vartmp_count = rt.get("vartmp_files_removed", 0) + if tmp_count > 0 or vartmp_count > 0: + verb = "would remove" if dry_run else "removed" + msg = f"Temp files: {verb} {tmp_count} from /tmp, {vartmp_count} from /var/tmp" + (_info if dry_run else _ok)(msg) + else: + _info("Temp files: none to remove") + + # Core dumps + rc = results.get("cores", {}) + if rc: + cores = rc.get("cores_planned", 0) if dry_run else rc.get("cores_removed", 0) + if cores > 0: + verb = "would remove" if dry_run else "removed" + msg = f"Core dumps: {verb} {cores} (older than {COREDUMP_MAX_AGE_DAYS} days)" + (_info if dry_run else _ok)(msg) + else: + _info("Core dumps: none to remove") + if rc.get("cores_kept_recent", 0) > 0: + _info(f"Core dumps kept (recent): {rc['cores_kept_recent']}") + + # Package audit (informational in both modes) + rp = results.get("packages", {}) + if rp: + extras = rp.get("extras_count", 0) + if extras > 0: + _warn(f"Packages not in any repo: {extras}") + else: + _ok("Package audit: all packages from known repos") + + # Total space (dry-run: consistent estimates across all sections) + total_space: int = 0 + for section in ["dnf", "journal", "tmp", "cores"]: + total_space += results.get(section, {}).get("space_saved", 0) + if dry_run: + _info(f"Estimated space to free: ~{_format_size(total_space)}") + elif total_space > 0: + _ok(f"Total space freed: {_format_size(total_space)}") + else: + _info("Total space freed: 0 B") + + if warnings: + print(file=sys.stderr) + for warning in warnings: + _warn(warning) + + 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 system cleanup and optimisation for Fedora and openEuler", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview without making changes", + ) + parser.add_argument( + "--skip-dnf", + action="store_true", + help="Skip DNF cleanup (autoremove, old kernels, cache)", + ) + parser.add_argument( + "--skip-journal", + action="store_true", + help="Skip journal vacuum", + ) + parser.add_argument( + "--skip-tmp", + action="store_true", + help="Skip temp file cleanup", + ) + parser.add_argument( + "--skip-cores", + action="store_true", + help="Skip core dump cleanup", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION}", + ) + return parser.parse_args(argv) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + """Entry point — detect OS, check root, then run enabled sections. + + Every section is isolated: a crashing or failing section is recorded and + reported, but never aborts the remaining sections or the summary. The + process exits non-zero when any operation failed. + """ + start_time = time.monotonic() + warnings: list[str] = [] + failures: list[str] = [] + results: dict[str, Any] = {} + + args = parse_args() + check_root() + os_id, os_display, version_id = detect_os() + dnf_major = _dnf_generation() + + # Header + print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + print( + f"{BOLD} System Optimise 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, + ) + print(file=sys.stderr) + + sections: list[tuple[str, str, Callable[[], dict[str, Any]], str | None]] = [ + ( + "dnf", + "DNF Cleanup", + lambda: dnf_cleanup(os_id, dnf_major, dry_run=args.dry_run, warnings=warnings), + "--skip-dnf" if args.skip_dnf else None, + ), + ( + "journal", + "Journal Cleanup", + lambda: journal_cleanup(dry_run=args.dry_run, warnings=warnings), + "--skip-journal" if args.skip_journal else None, + ), + ( + "systemd", + "systemd Audit", + lambda: systemd_audit(warnings=warnings), + None, + ), + ( + "tmp", + "Temp File Cleanup", + lambda: temp_cleanup(dry_run=args.dry_run, warnings=warnings), + "--skip-tmp" if args.skip_tmp else None, + ), + ( + "cores", + "Core Dump Cleanup", + lambda: coredump_cleanup(dry_run=args.dry_run, warnings=warnings), + "--skip-cores" if args.skip_cores else None, + ), + ( + "packages", + "Package Audit", + lambda: package_audit(dnf_major=dnf_major, warnings=warnings), + None, + ), + ] + for key, title, section_func, skip_flag in sections: + print(f"\n{BOLD}── {title} ──{RESET}", file=sys.stderr) + if skip_flag is not None: + _info(f"{title}: skipped ({skip_flag})") + continue + try: + results[key] = section_func() + except Exception as exc: # isolation: one section must not kill the rest + _fail(f"{title} crashed: {exc}") + failures.append(f"{title}: crashed ({exc})") + continue + for error in results[key].get("errors", []): + failures.append(f"{title}: {error}") + + elapsed = time.monotonic() - start_time + print_summary(os_display, version_id, results, warnings, elapsed, dry_run=args.dry_run) + + if failures: + print(f"{RED}{BOLD}Failed operations ({len(failures)}):{RESET}", file=sys.stderr) + for failure in failures: + _fail(failure) + print(file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/vllm-deploy.py b/vllm-deploy.py new file mode 100644 index 0000000..6605454 --- /dev/null +++ b/vllm-deploy.py @@ -0,0 +1,2153 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +# SPDX-License-Identifier: MIT + +"""Idempotent vLLM deployment script for AMD ROCm GPUs. + +Deploys vLLM behind nginx with self-signed TLS on Fedora or openEuler. +vLLM binds to ::1 (IPv4 loopback fallback) on port 8000, internal only; +nginx proxies :443 → the loopback upstream with streaming (SSE) support. +The endpoint requires an API key, delivered to the service via a 0600 +EnvironmentFile; nginx→vLLM proxying is allowed through SELinux on +enforcing systems. Fedora gets ROCm from its native repositories; +openEuler falls back to AMD's amdgpu-install (unsupported by AMD). + +No external dependencies — stdlib and system tools only. Every operation +checks current state before acting; running the script repeatedly produces +the same result with no errors and no duplicate work. + +Usage: + vllm-deploy.py # interactive model selection + vllm-deploy.py --model Qwen/Qwen3.6-35B-A3B # deploy specific model + vllm-deploy.py --model Qwen/Qwen3.6-35B-A3B --dry-run # preview + vllm-deploy.py --uninstall # tear down + vllm-deploy.py --version +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import secrets +import shutil +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +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", + "openeuler": "openEuler", +} + +# Tools this script itself needs (lspci, curl fetches, openssl for TLS). +DNF_PACKAGES: list[str] = ["pciutils", "curl", "openssl"] + +# Fedora ships ROCm natively (maintained by the Fedora ROCm SIG) — preferred +# over AMD's amdgpu-install, which does not support Fedora. +FEDORA_ROCM_PACKAGES: list[str] = [ + "rocm-hip", + "rocm-opencl", + "rocm-runtime", + "rocm-smi", + "rocminfo", +] + +# Default models for interactive selection when --model is omitted. +# All IDs verified to exist (ungated) on Hugging Face as of 2026-07. +DEFAULT_MODELS: list[str] = [ + "deepseek-ai/DeepSeek-V4-Pro", + "deepseek-ai/DeepSeek-V4-Flash", + "MiniMaxAI/MiniMax-M3", + "Qwen/Qwen3.6-35B-A3B", + "Qwen/Qwen3.6-27B", +] + +# vLLM ROCm wheel resolution, per the official vLLM ROCm install docs: +# https://docs.vllm.ai/en/stable/getting_started/installation/gpu/ +# The newest pin is scraped from the index at runtime; the constants below +# are the fallback (current stable example from the vLLM documentation). +WHEELS_INDEX = "https://wheels.vllm.ai/rocm" +FALLBACK_VLLM_VERSION = "0.25.0" +FALLBACK_ROCM_VARIANT = "rocm723" + +AMDGPU_INSTALL_URL = "https://repo.radeon.com/amdgpu-install/latest/rhel" + +# HuggingFace model IDs look like "org/name". The strict pattern also keeps +# systemd specifier characters (%) and whitespace out of unit files. +MODEL_ID_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") + + +# --------------------------------------------------------------------------- +# 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, +) -> 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. + """ + 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, check=check + ) + + +def _find_uv() -> str | None: + """Locate the uv binary: PATH first, then well-known install locations.""" + uv = shutil.which("uv") + if uv: + return uv + candidates = ( + "/usr/local/bin/uv", + "/root/.local/bin/uv", + os.path.expanduser("~/.local/bin/uv"), + ) + for candidate in candidates: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + +def _fetch_text(url: str, timeout: int = 30) -> str: + """Fetch a URL as text via curl -fsSL; return '' on failure (with a warning).""" + curl = shutil.which("curl") + if curl is None: + _warn("curl not found — cannot fetch remote version information") + return "" + try: + result = run([curl, "-fsSL", url], timeout=timeout) + except (OSError, subprocess.SubprocessError) as exc: + _warn(f"Failed to fetch {url}: {exc}") + return "" + if result.returncode != 0: + _warn(f"Failed to fetch {url} (curl exit {result.returncode})") + return "" + return result.stdout + + +# --------------------------------------------------------------------------- +# 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 vllm-deploy.py --model ...", + 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 _user_exists(username: str) -> bool: + """Return True if the system user exists.""" + result = run(["id", username]) + return result.returncode == 0 + + +def _group_exists(groupname: str) -> bool: + """Return True if the system group exists.""" + result = run(["getent", "group", groupname]) + return result.returncode == 0 + + +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 _map_to_el_major(version_id: str) -> str: + """Map an openEuler version to the closest RHEL major ("8"/"9") for AMDGPU repos.""" + try: + major_ver = int(version_id.split(".")[0]) + except (ValueError, IndexError): + major_ver = 0 + return "9" if major_ver >= 24 else "8" + + +# --------------------------------------------------------------------------- +# GPU, ROCm and wheel-pin resolution helpers +# --------------------------------------------------------------------------- + + +def _detect_amd_gpus() -> list[str]: + """Return AMD/ATI GPU descriptions from lspci -nn (empty list when none). + + Only VGA/3D/Display controller class lines with the AMD/ATI vendor ID + (1002) match — AMD chipset, audio and USB lines found on AMD-CPU systems + are correctly ignored. + """ + lspci = shutil.which("lspci") + if lspci is None: + _fail("lspci not found — pciutils should have been installed in the dependencies step") + sys.exit(1) + result = run([lspci, "-nn"], timeout=30) + if result.returncode != 0: + _fail(f"lspci -nn failed: {result.stderr.strip()}") + sys.exit(1) + + gpu_classes = ("VGA compatible controller", "3D controller", "Display controller") + gpus: list[str] = [] + for line in result.stdout.split("\n"): + if "[1002:" in line and any(cls in line for cls in gpu_classes): + gpus.append(line.split(": ", 1)[-1].strip() if ": " in line else line.strip()) + return gpus + + +def _detect_rocm_version() -> str | None: + """Return the installed ROCm version (e.g. '7.1.0'), or None if not found.""" + info_file = "/opt/rocm/.info/version" + if _file_exists(info_file): + try: + with open(info_file, encoding="utf-8") as fh: + version = fh.read().strip().split("-")[0] + if version: + return version + except OSError: + pass + # Fedora native packages install into /usr (no /opt/rocm). + result = run(["rpm", "-q", "--qf", "%{VERSION}", "rocm-runtime"], timeout=30) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + smi = shutil.which("rocm-smi") + if smi: + result = run([smi, "--version"], timeout=30) + match = re.search(r"(\d+\.\d+(?:\.\d+)?)", result.stdout + result.stderr) + if match: + return match.group(1) + return None + + +def _variant_to_version(variant: str) -> str: + """Map a wheel ROCm variant ('rocm700', 'rocm723') to a dotted version.""" + digits = variant.removeprefix("rocm") + return ".".join(digits) # '700' -> '7.0.0', '723' -> '7.2.3' + + +def _check_rocm_compat(rocm_version: str | None, variant: str) -> None: + """Warn when the installed ROCm major version differs from the wheel's target.""" + if rocm_version is None: + _warn("Could not determine the ROCm version — cannot verify wheel compatibility") + return + required = _variant_to_version(variant) + if rocm_version.split(".")[0] != required.split(".")[0]: + _warn( + f"ROCm {rocm_version} is installed but the vLLM wheel targets ROCm {required} " + f"({variant}) — runtime errors are likely; align the versions" + ) + + +def _resolve_vllm_pin() -> tuple[str, str, str]: + """Resolve (version, rocm_variant, index_url) for the newest stable ROCm wheel. + + Scrapes the vLLM wheel index as recommended by the vLLM documentation; + falls back to FALLBACK_VLLM_VERSION/FALLBACK_ROCM_VARIANT when the index + is unreachable or unparsable. + """ + listing = _fetch_text(f"{WHEELS_INDEX}/vllm") + candidates = re.findall(r"vllm-(\d+\.\d+\.\d+)\+(rocm\d+)-", listing) + if not candidates: + if listing: + _warn("Could not parse the vLLM wheel index — using the fallback pin") + return ( + FALLBACK_VLLM_VERSION, + FALLBACK_ROCM_VARIANT, + f"{WHEELS_INDEX}/{FALLBACK_VLLM_VERSION}/{FALLBACK_ROCM_VARIANT}", + ) + version, variant = max(candidates, key=lambda c: ([int(p) for p in c[0].split(".")], c[1])) + return version, variant, f"{WHEELS_INDEX}/{version}/{variant}" + + +def _hf_model_status(model: str) -> str: + """Probe Hugging Face for the model: 'ok', 'gated', 'missing', or 'unknown'. + + Only a definitive 404 ('missing') is fatal; network problems yield + 'unknown' so offline environments are not blocked. + """ + url = f"https://huggingface.co/api/models/{model}" + request = urllib.request.Request(url, headers={"User-Agent": f"vllm-deploy/{VERSION}"}) + try: + with urllib.request.urlopen(request, timeout=15) as response: + payload = json.loads(response.read().decode("utf-8", "replace")) + return "gated" if payload.get("gated") not in (False, None) else "ok" + except urllib.error.HTTPError as exc: + if exc.code == 404: + return "missing" + if exc.code in (401, 403): + return "gated" + return "unknown" + except (urllib.error.URLError, OSError, json.JSONDecodeError): + return "unknown" + + +def _detect_loopback() -> tuple[str, str]: + """Return (vllm_bind_host, nginx_upstream_host) — IPv6 ::1 first. + + Falls back to 127.0.0.1 on kernels with IPv6 disabled + (net.ipv6.conf.all.disable_ipv6=1), where binding ::1 would fail. + """ + if os.path.exists("/proc/net/if_inet6"): + return "::1", "[::1]" + return "127.0.0.1", "127.0.0.1" + + +def _cert_san() -> str: + """Build a subjectAltName value from the host FQDN and primary IPv4 (if resolvable).""" + fqdn = socket.getfqdn() + hostname = socket.gethostname() + entries = [f"DNS:{fqdn}"] + if hostname and hostname != fqdn: + entries.append(f"DNS:{hostname}") + try: + addr = socket.gethostbyname(fqdn) + if addr and not addr.startswith("127."): + entries.append(f"IP:{addr}") + except OSError: + pass + return ",".join(entries) + + +# --------------------------------------------------------------------------- +# 1. System dependencies +# --------------------------------------------------------------------------- + + +def install_system_deps(dry_run: bool = False) -> dict[str, Any]: + """Install required system packages via dnf. Idempotent.""" + results: dict[str, Any] = {"installed": [], "skipped": [], "failed": []} + + missing: list[str] = [] + for pkg in DNF_PACKAGES: + if _rpm_installed(pkg): + results["skipped"].append(pkg) + else: + missing.append(pkg) + + if not missing: + _info(f"All {len(DNF_PACKAGES)} packages already installed") + else: + _status(f"Installing {len(missing)} package(s): {', '.join(missing)}") + if dry_run: + _status_done("dry run") + results["installed"] = missing + else: + result = run(["dnf", "install", "-y", *missing], timeout=600) + if result.returncode == 0: + results["installed"] = missing + _status_done("ok") + else: + _status_done("failed") + results["failed"] = missing + if result.stderr: + _info(f"dnf stderr: {result.stderr.strip()}") + + # Install uv: dnf first (packaged in Fedora), standalone installer as + # fallback. Never pip-install into the system Python. + _status("Checking uv") + uv = _find_uv() + if uv is not None: + _status_done("already installed") + results["uv"] = uv + elif dry_run: + _status_done("would install via dnf") + results["uv"] = "dry run" + else: + uv_dnf = run(["dnf", "install", "-y", "uv"], timeout=300) + uv = _find_uv() if uv_dnf.returncode == 0 else None + if uv is not None: + _status_done(f"installed via dnf ({uv})") + results["uv"] = uv + else: + _info("uv not in distro repos — trying the official standalone installer") + installer = run( + ["sh", "-c", "curl -fsSL https://astral.sh/uv/install.sh | sh"], + timeout=300, + ) + uv = _find_uv() if installer.returncode == 0 else None + if uv is not None: + _status_done(f"installed via installer ({uv})") + results["uv"] = uv + else: + _status_done("failed") + results["uv"] = None + _fail("uv could not be installed — vLLM venv creation will fail") + + # Packages expected from server-setup.py (warning only, not installed here). + required_server_packages: dict[str, str] = { + "nginx": "reverse proxy", + "git": "source control", + "python3": "Python runtime", + "python3-devel": "Python development headers", + } + for pkg, purpose in required_server_packages.items(): + if not _rpm_installed(pkg): + _warn(f"{pkg} ({purpose}) is not installed — run server-setup.py first") + + return results + + +# --------------------------------------------------------------------------- +# 2. Pre-flight checks +# --------------------------------------------------------------------------- + + +def _install_rocm_fedora(dry_run: bool) -> None: + """Install ROCm from Fedora's native repositories (Fedora ROCm SIG).""" + _status("Installing ROCm from Fedora repositories (this may take several minutes)") + missing = [pkg for pkg in FEDORA_ROCM_PACKAGES if not _rpm_installed(pkg)] + if not missing: + _status_done("already installed") + return + if dry_run: + _status_done("dry run") + _info(f"Would run: dnf install -y {' '.join(missing)}") + return + result = run(["dnf", "install", "-y", *missing], timeout=1800) + if result.returncode != 0: + _status_done("failed") + print( + f"\n {RED}{BOLD}Error:{RESET} Failed to install ROCm packages.", + file=sys.stderr, + ) + if result.stderr: + _info(f"dnf stderr: {result.stderr.strip()}") + sys.exit(1) + _status_done("ok") + + +def _install_rocm_openeuler(version_id: str, dry_run: bool) -> None: + """Install ROCm on openEuler via AMD's amdgpu-install script. + + AMD does not list openEuler as a supported ROCm platform — the RHEL + packages are installed strictly on a best-effort basis and may fail. + The RHEL point-release directory and RPM filename are resolved + dynamically from the repository (nothing is hardcoded). + """ + _warn( + "AMD does not support ROCm on openEuler — amdgpu-install targets RHEL. " + "Continuing best-effort; this may fail or produce a broken install." + ) + el_major = _map_to_el_major(version_id) + + _status(f"Resolving latest amdgpu-install release for el{el_major}") + listing = _fetch_text(f"{AMDGPU_INSTALL_URL}/") + rhel_dirs = [ + d for d in re.findall(r'href="(\d+\.\d+)/"', listing) if d.startswith(f"{el_major}.") + ] + rhel_version = max(rhel_dirs, key=lambda v: [int(p) for p in v.split(".")], default=None) + rpm_filename = "" + if rhel_version: + rpm_listing = _fetch_text(f"{AMDGPU_INSTALL_URL}/{rhel_version}/") + match = re.search(rf"amdgpu-install-[\d.]+-\d+\.el{el_major}\.noarch\.rpm", rpm_listing) + if match: + rpm_filename = match.group(0) + + if dry_run: + _status_done("dry run") + if rpm_filename: + _info(f"Would fetch: {AMDGPU_INSTALL_URL}/{rhel_version}/{rpm_filename}") + else: + _warn(f"Could not resolve the latest RPM — check {AMDGPU_INSTALL_URL}/") + _info("Would run: amdgpu-install -y --usecase=rocm") + return + + if not rpm_filename or rhel_version is None: + _status_done("failed") + print( + f"\n {RED}{BOLD}Error:{RESET} Could not resolve the latest amdgpu-install " + f"RPM under {AMDGPU_INSTALL_URL}/.", + file=sys.stderr, + ) + sys.exit(1) + + rpm_url = f"{AMDGPU_INSTALL_URL}/{rhel_version}/{rpm_filename}" + ver_match = re.match(r"amdgpu-install-([\d.]+)", rpm_filename) + detected = ver_match.group(1) if ver_match else "latest" + _status_done(f"amdgpu-install {detected} (rhel {rhel_version})") + + _status(f"Installing amdgpu-install {detected}") + install_result = run(["dnf", "install", "-y", rpm_url], timeout=300) + if install_result.returncode != 0: + _status_done("failed") + print( + f"\n {RED}{BOLD}Error:{RESET} Failed to install amdgpu-install.", + file=sys.stderr, + ) + print(f" URL: {rpm_url}", file=sys.stderr) + if install_result.stderr: + _info(f"dnf stderr: {install_result.stderr.strip()}") + sys.exit(1) + _status_done("ok") + + _status("Installing ROCm stack (this may take several minutes)") + rocm_result = run(["amdgpu-install", "-y", "--usecase=rocm"], timeout=1800) + if rocm_result.returncode != 0: + _status_done("failed") + print( + f"\n {RED}{BOLD}Error:{RESET} amdgpu-install failed.", + file=sys.stderr, + ) + if rocm_result.stderr: + _info(f"stderr: {rocm_result.stderr.strip()}") + sys.exit(1) + _status_done("ok") + + +def preflight_checks(os_id: str, version_id: str, dry_run: bool = False) -> dict[str, Any]: + """Run pre-flight checks: root, GPU hardware, wheel pin, ROCm. + + Returns a dict with check results (including the resolved vLLM pin). + Exits with a clear error on any hard failure. + """ + results: dict[str, Any] = {} + + # Root (idempotent no-op — main() already checked; keeps the status line). + _status("Checking root privileges") + check_root() + _status_done("root") + + # OS (already detected by main for the header). + _status("Detecting operating system") + _status_done(f"{SUPPORTED_OS[os_id]} {version_id}") + results["os_id"] = os_id + results["os_display"] = SUPPORTED_OS[os_id] + results["version_id"] = version_id + + # AMD GPU hardware — class + vendor filtered, so AMD chipsets never match. + _status("Checking GPU hardware") + gpus = _detect_amd_gpus() + if not gpus: + _status_done("not found") + print( + f"\n {RED}{BOLD}Error:{RESET} No AMD GPU detected in this system — " + "vLLM requires an AMD GPU with ROCm support.", + file=sys.stderr, + ) + sys.exit(1) + if len(gpus) == 1: + _status_done(gpus[0]) + else: + _status_done(f"{gpus[0]} (+{len(gpus) - 1} more)") + results["gpu_models"] = gpus + results["gpu_count"] = len(gpus) + + # Resolve the vLLM ROCm wheel pin (also used for the ROCm version check). + _status("Resolving vLLM ROCm wheel version") + vllm_version, rocm_variant, wheel_index = _resolve_vllm_pin() + _status_done(f"vllm=={vllm_version}+{rocm_variant}") + results["vllm_version"] = vllm_version + results["rocm_variant"] = rocm_variant + results["wheel_index"] = wheel_index + + # ROCm + _status("Checking ROCm installation") + rocm_version = _detect_rocm_version() + rocm_present = ( + rocm_version is not None or _dir_exists("/opt/rocm") or shutil.which("rocm-smi") is not None + ) + if rocm_present: + _status_done(f"present ({rocm_version or 'unknown version'})") + results["rocm"] = True + _check_rocm_compat(rocm_version, rocm_variant) + else: + _status_done("not found") + if os_id == "fedora": + _install_rocm_fedora(dry_run) + else: + _install_rocm_openeuler(version_id, dry_run) + results["rocm"] = True + + # GPU verification via rocm-smi. + _status("Verifying GPU via rocm-smi") + if dry_run and not rocm_present: + _status_done("would verify") + _info("Would run: rocm-smi --showproductname") + return results + smi = shutil.which("rocm-smi") + if smi is None and _file_exists("/opt/rocm/bin/rocm-smi"): + smi = "/opt/rocm/bin/rocm-smi" + if smi is None: + _status_done("rocm-smi not found") + print( + f"\n {RED}{BOLD}Error:{RESET} rocm-smi not found — ROCm installation " + "appears incomplete.", + file=sys.stderr, + ) + sys.exit(1) + gpu_result = run([smi, "--showproductname"], timeout=30) + if gpu_result.returncode != 0: + _status_done("no GPU detected") + print( + f"\n {RED}{BOLD}Error:{RESET} No AMD GPU detected via rocm-smi.", + file=sys.stderr, + ) + print( + " ROCm installed but no compatible GPU found.", + file=sys.stderr, + ) + if gpu_result.stderr: + print(f" rocm-smi stderr: {gpu_result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + _status_done("available") + # Count unique GPU[n] indices — rocm-smi prints several lines per GPU. + indices = set(re.findall(r"GPU\[(\d+)\]", gpu_result.stdout)) + if indices: + results["gpu_count"] = len(indices) + results["gpu_info"] = gpu_result.stdout.strip() + + return results + + +# --------------------------------------------------------------------------- +# 3. Create vllm system user +# --------------------------------------------------------------------------- + + +def setup_vllm_user(dry_run: bool = False) -> dict[str, Any]: + """Create the vllm system user and add to GPU groups. Idempotent.""" + results: dict[str, Any] = {} + + _status("Checking vllm system user") + if _user_exists("vllm"): + _status_done("already exists") + results["user_exists"] = True + else: + if dry_run: + _status_done("would create") + results["user_created"] = "dry run" + else: + result = run( + ["useradd", "-r", "-s", "/sbin/nologin", "-d", "/opt/vllm", "vllm"], + timeout=30, + ) + if result.returncode == 0: + _status_done("created") + results["user_created"] = True + else: + _status_done("failed") + _fail(f"useradd failed: {result.stderr.strip()}") + results["user_created"] = False + return results + + # Add to render and video groups for GPU access. + for group in ("render", "video"): + _status(f"Adding vllm to {group} group") + if not _group_exists(group): + _status_done("group does not exist") + _warn(f"Group '{group}' not found — GPU access may be limited") + continue + + if dry_run: + _status_done("dry run") + continue + + result = run(["usermod", "-aG", group, "vllm"], timeout=30) + if result.returncode == 0: + _status_done("ok") + else: + _status_done("failed") + _warn(f"Failed to add vllm to {group}: {result.stderr.strip()}") + + return results + + +# --------------------------------------------------------------------------- +# 4. Python venv with uv + vLLM for ROCm +# --------------------------------------------------------------------------- + + +def _chown_vllm(path: str, recursive: bool = False) -> None: + """chown a path to vllm:vllm, warning (not crashing) on failure.""" + cmd = ["chown"] + (["-R"] if recursive else []) + ["vllm:vllm", path] + result = run(cmd, timeout=60) + if result.returncode != 0: + _warn(f"chown failed for {path}: {result.stderr.strip()}") + + +def _installed_vllm_version(uv: str | None, venv_dir: str) -> str | None: + """Return the vLLM version installed in the venv, or None if unknown.""" + if uv is None: + return None + result = run([uv, "pip", "show", "--python", f"{venv_dir}/bin/python", "vllm"], timeout=60) + if result.returncode != 0: + return None + for line in result.stdout.split("\n"): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + return None + + +def setup_venv( + vllm_version: str, + rocm_variant: str, + index_url: str, + venv_dir: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Create Python venv and install vLLM for ROCm via uv. Idempotent.""" + results: dict[str, Any] = {} + + # Ensure /opt/vllm exists and is owned by vllm. + _status("Ensuring /opt/vllm directory") + if not _dir_exists("/opt/vllm"): + if dry_run: + _status_done("would create") + else: + os.makedirs("/opt/vllm", exist_ok=True) + _chown_vllm("/opt/vllm") + _status_done("created") + else: + _status_done("exists") + + # Create venv if missing. ROCm wheels require Python 3.12 exactly. + _status("Creating Python venv") + uv = _find_uv() + if _dir_exists(venv_dir): + _status_done("already exists") + results["venv_exists"] = True + else: + if dry_run: + _status_done("dry run") + results["venv_created"] = "dry run" + else: + if uv is None: + _status_done("uv not found") + _fail("uv is not installed — cannot create the venv") + results["venv_created"] = False + return results + venv_result = run([uv, "venv", "--python", "3.12", venv_dir], timeout=300) + if venv_result.returncode == 0: + _status_done("created") + results["venv_created"] = True + else: + _status_done("failed") + _fail(f"uv venv failed: {venv_result.stderr.strip()}") + results["venv_created"] = False + return results + + if not dry_run and _dir_exists(venv_dir): + _chown_vllm(venv_dir, recursive=True) + + # Idempotency: uv venvs have no pip — check for the vllm entry point and + # query the installed version via uv instead. + vllm_bin = f"{venv_dir}/bin/vllm" + _status("Checking vLLM installation") + if os.path.isfile(vllm_bin): + installed = _installed_vllm_version(uv, venv_dir) + _status_done(f"already installed (v{installed})" if installed else "already installed") + results["vllm_installed"] = True + results["vllm_version"] = installed or "unknown" + if installed and installed != vllm_version: + _warn( + f"Installed vLLM {installed} differs from the resolved pin {vllm_version} " + "— keeping the installed version (uninstall first to switch)" + ) + return results + + if dry_run: + _status_done("dry run") + _info( + f"Would run: uv pip install --python {venv_dir}/bin/python " + f"vllm=={vllm_version}+{rocm_variant} --extra-index-url {index_url}" + ) + results["vllm_installed"] = "dry run" + return results + + if uv is None: + _status_done("uv not found") + _fail("uv is not installed — cannot install vLLM") + results["vllm_installed"] = False + return results + + _status(f"Installing vllm=={vllm_version}+{rocm_variant} (this may take several minutes)") + install_result = run( + [ + uv, + "pip", + "install", + "--python", + f"{venv_dir}/bin/python", + f"vllm=={vllm_version}+{rocm_variant}", + "--extra-index-url", + index_url, + ], + timeout=1800, # 30 min — vLLM is a large package + ) + if install_result.returncode == 0: + _status_done("ok") + results["vllm_installed"] = True + results["vllm_version"] = vllm_version + else: + _status_done("failed") + _fail(f"vLLM install failed: {install_result.stderr.strip()[-500:]}") + results["vllm_installed"] = False + + if not dry_run and _dir_exists(venv_dir): + _chown_vllm(venv_dir, recursive=True) + + return results + + +# --------------------------------------------------------------------------- +# 5. TLS certificate (self-signed) +# --------------------------------------------------------------------------- + + +def setup_tls(cert_dir: str, dry_run: bool = False) -> dict[str, Any]: + """Generate a self-signed TLS certificate (with SAN). Idempotent.""" + results: dict[str, Any] = {} + key_path = os.path.join(cert_dir, "vllm.key") + crt_path = os.path.join(cert_dir, "vllm.crt") + + _status("Checking TLS certificate") + if _file_exists(crt_path) and _file_exists(key_path): + _status_done("already exists") + results["cert_exists"] = True + return results + + if dry_run: + _status_done("would generate") + results["cert_created"] = "dry run" + return results + + openssl = shutil.which("openssl") + if openssl is None: + _status_done("openssl not found") + _fail("openssl is not installed — cannot generate a certificate") + results["cert_created"] = False + return results + + if not _dir_exists(cert_dir): + os.makedirs(cert_dir, exist_ok=True) + + _status("Generating self-signed certificate") + result = run( + [ + openssl, + "req", + "-x509", + "-nodes", + "-days", + "365", + "-newkey", + "rsa:2048", + "-keyout", + key_path, + "-out", + crt_path, + "-subj", + f"/CN={socket.getfqdn()}", + "-addext", + f"subjectAltName={_cert_san()}", + ], + timeout=30, + ) + if result.returncode == 0: + os.chmod(key_path, 0o600) + os.chmod(crt_path, 0o644) + _status_done("generated") + results["cert_created"] = True + else: + _status_done("failed") + _fail(f"openssl failed: {result.stderr.strip()}") + results["cert_created"] = False + + return results + + +# --------------------------------------------------------------------------- +# 6. API key (EnvironmentFile for the systemd unit) +# --------------------------------------------------------------------------- + + +def setup_api_key( + service_name: str, + api_key: str | None, + dry_run: bool = False, +) -> dict[str, Any]: + """Store the vLLM API key in a 0600 EnvironmentFile. Idempotent. + + The key comes from --api-key when given, otherwise it is generated once + and reused on subsequent runs. systemd (PID 1, root) reads the file, so + 0600 root:vllm is sufficient — the service user never opens it. + """ + results: dict[str, Any] = {} + env_path = f"/etc/sysconfig/{service_name}" + + current = "" + if _file_exists(env_path): + try: + with open(env_path, encoding="utf-8") as fh: + current = fh.read() + except OSError: + current = "" + + _status("Checking API key environment file") + if api_key is None and "VLLM_API_KEY=" in current: + _status_done("already present — reusing existing key") + results["env_file"] = "exists" + return results + + desired = f"VLLM_API_KEY={api_key}\n" if api_key else "" + if api_key is not None and current == desired: + _status_done("already configured") + results["env_file"] = "exists" + return results + + if dry_run: + _status_done("would update" if current else "would create") + results["env_file"] = "dry run" + return results + + key = api_key if api_key is not None else secrets.token_urlsafe(32) + try: + with open(env_path, "w", encoding="utf-8") as fh: + fh.write(f"VLLM_API_KEY={key}\n") + except OSError as exc: + _status_done("failed") + _fail(f"Could not write {env_path}: {exc}") + results["env_file"] = "failed" + return results + os.chmod(env_path, 0o600) + result = run(["chown", "root:vllm", env_path], timeout=30) + if result.returncode != 0: + _warn(f"chown failed for {env_path}: {result.stderr.strip()}") + _status_done("updated" if current else "created") + results["env_file"] = "updated" if current else "created" + if api_key is None: + results["generated_key"] = key + return results + + +# --------------------------------------------------------------------------- +# 7. SELinux +# --------------------------------------------------------------------------- + + +def setup_selinux(dry_run: bool = False) -> dict[str, Any]: + """Allow nginx→vLLM proxying on SELinux-enforcing systems. Idempotent. + + http_port_t covers 80/81/443/488/8008/8009/8443/9000 — not vLLM's port — + so on enforcing systems nginx needs httpd_can_network_connect. + """ + results: dict[str, Any] = {} + + _status("Checking SELinux status") + getenforce = shutil.which("getenforce") + if getenforce is None: + _status_done("not installed — skipping") + results["selinux"] = "absent" + return results + mode_result = run([getenforce], timeout=10) + mode = mode_result.stdout.strip().lower() + if mode != "enforcing": + _status_done(f"{mode} — skipping") + results["selinux"] = mode + return results + _status_done("enforcing") + + _status("Checking httpd_can_network_connect boolean") + getsebool = shutil.which("getsebool") + setsebool = shutil.which("setsebool") + if getsebool is None or setsebool is None: + _status_done("tools missing") + _warn("getsebool/setsebool not found — nginx proxying may be blocked (502)") + results["selinux"] = "failed" + return results + + current = run([getsebool, "httpd_can_network_connect"], timeout=10) + if current.returncode == 0 and "--> on" in current.stdout: + _status_done("already on") + results["selinux"] = "on" + return results + + if dry_run: + _status_done("would set on") + results["selinux"] = "dry run" + return results + + # -P persists across reboots; the policy rebuild can take a while. + set_result = run([setsebool, "-P", "httpd_can_network_connect=1"], timeout=180) + if set_result.returncode == 0: + _status_done("set on") + results["selinux"] = "on" + else: + _status_done("failed") + _warn(f"setsebool failed: {set_result.stderr.strip()}") + results["selinux"] = "failed" + + return results + + +# --------------------------------------------------------------------------- +# 8. nginx configuration +# --------------------------------------------------------------------------- + + +def _nginx_conf_content(port: int, upstream_host: str, cert_dir: str) -> str: + """Return the nginx vllm server block content. + + HTTP/1.1 + ``Connection ""`` + ``proxy_buffering off`` are required for + vLLM's SSE streaming — nginx's defaults (HTTP/1.0, buffering on) would + hold a whole streamed completion until generation finishes. + """ + return f"""server {{ + listen [::]:443 ssl; + listen 443 ssl; + server_name _; + + ssl_certificate {cert_dir}/vllm.crt; + ssl_certificate_key {cert_dir}/vllm.key; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 50m; + + location / {{ + proxy_pass http://{upstream_host}:{port}; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + }} +}} +""" + + +def setup_nginx( + port: int, + upstream_host: str, + cert_dir: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Configure nginx as reverse proxy for vLLM. Idempotent.""" + results: dict[str, Any] = {} + conf_path = "/etc/nginx/conf.d/vllm.conf" + desired_content = _nginx_conf_content(port, upstream_host, cert_dir) + + # Write nginx config if missing or different. + _status("Checking nginx configuration") + if _file_exists(conf_path): + try: + with open(conf_path, encoding="utf-8") as fh: + current = fh.read() + except OSError: + current = "" + if current.strip() == desired_content.strip(): + _status_done("already configured") + results["nginx_configured"] = True + else: + if dry_run: + _status_done("would update") + results["nginx_configured"] = "dry run" + else: + with open(conf_path, "w", encoding="utf-8") as fh: + fh.write(desired_content) + _status_done("updated") + results["nginx_configured"] = True + else: + if dry_run: + _status_done("would create") + results["nginx_configured"] = "dry run" + else: + with open(conf_path, "w", encoding="utf-8") as fh: + fh.write(desired_content) + _status_done("created") + results["nginx_configured"] = True + + # Ensure nginx is enabled and running (handles the enabled-but-stopped case). + _status("Ensuring nginx service is enabled and running") + nginx_running = _systemctl_is_active("nginx") + if _systemctl_is_enabled("nginx") and nginx_running: + _status_done("running") + results["nginx_running"] = True + elif dry_run: + _status_done("dry run") + results["nginx_running"] = "dry run" + else: + if not _systemctl_is_enabled("nginx"): + start_result = run(["systemctl", "enable", "--now", "nginx"], timeout=30) + else: + start_result = run(["systemctl", "start", "nginx"], timeout=30) + if start_result.returncode == 0: + _status_done("enabled and started") + results["nginx_running"] = True + else: + _status_done("failed") + _warn(f"Could not start nginx: {start_result.stderr.strip()}") + results["nginx_running"] = False + + # Test and reload the configuration (only possible when nginx is running). + _status("Reloading nginx configuration") + if dry_run: + _status_done("dry run") + results["nginx_reloaded"] = "dry run" + elif not results.get("nginx_running"): + _status_done("skipped — nginx not running") + results["nginx_reloaded"] = False + else: + test_result = run(["nginx", "-t"], timeout=30) + if test_result.returncode != 0: + _status_done("config test failed") + _fail(f"nginx -t failed: {test_result.stderr.strip()}") + results["nginx_reloaded"] = False + else: + reload_result = run(["systemctl", "reload", "nginx"], timeout=30) + if reload_result.returncode == 0: + _status_done("reloaded") + results["nginx_reloaded"] = True + else: + _status_done("failed") + _fail(f"nginx reload failed: {reload_result.stderr.strip()}") + results["nginx_reloaded"] = False + + return results + + +# --------------------------------------------------------------------------- +# 9. systemd service +# --------------------------------------------------------------------------- + + +def _systemd_content( + model: str, + port: int, + host: str, + tensor_parallel: int, + max_model_len: int, + gpu_memory_utilization: float, + venv_dir: str, + env_file: str, +) -> str: + """Return the systemd unit file content. + + The API key is substituted by systemd from the EnvironmentFile + (${VLLM_API_KEY}) — the key never appears in the unit file itself. + """ + return f"""[Unit] +Description=vLLM Inference Server ({model}) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=vllm +Group=vllm +WorkingDirectory=/opt/vllm +Environment=PYTHONUNBUFFERED=1 +EnvironmentFile={env_file} +ExecStart={venv_dir}/bin/vllm serve {model} \\ + --host {host} \\ + --port {port} \\ + --api-key ${{VLLM_API_KEY}} \\ + --tensor-parallel-size {tensor_parallel} \\ + --max-model-len {max_model_len} \\ + --gpu-memory-utilization {gpu_memory_utilization} +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +""" + + +def setup_systemd( + model: str, + port: int, + host: str, + tensor_parallel: int, + max_model_len: int, + gpu_memory_utilization: float, + venv_dir: str, + service_name: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Create and enable the vLLM systemd service. Idempotent. + + When the unit file changed and the service is already active, it is + restarted (try-restart) so config changes actually take effect. + """ + results: dict[str, Any] = {} + unit_path = f"/etc/systemd/system/{service_name}.service" + env_file = f"/etc/sysconfig/{service_name}" + desired_content = _systemd_content( + model, + port, + host, + tensor_parallel, + max_model_len, + gpu_memory_utilization, + venv_dir, + env_file, + ) + + # Write unit file if missing or different. + unit_changed = False + _status(f"Checking {service_name}.service unit file") + if _file_exists(unit_path): + try: + with open(unit_path, encoding="utf-8") as fh: + current = fh.read() + except OSError: + current = "" + if current.strip() == desired_content.strip(): + _status_done("already configured") + results["unit_configured"] = True + else: + if dry_run: + _status_done("would update") + results["unit_configured"] = "dry run" + else: + with open(unit_path, "w", encoding="utf-8") as fh: + fh.write(desired_content) + _status_done("updated") + results["unit_configured"] = True + unit_changed = True + else: + if dry_run: + _status_done("would create") + results["unit_configured"] = "dry run" + else: + with open(unit_path, "w", encoding="utf-8") as fh: + fh.write(desired_content) + _status_done("created") + results["unit_configured"] = True + unit_changed = True + results["unit_changed"] = unit_changed + + # systemctl daemon-reload. + _status("Reloading systemd daemon") + if dry_run: + _status_done("dry run") + else: + run(["systemctl", "daemon-reload"], timeout=30) + _status_done("ok") + + # Enable the service. + _status(f"Enabling {service_name} service") + if _systemctl_is_enabled(service_name): + _status_done("already enabled") + results["service_enabled"] = True + else: + if dry_run: + _status_done("dry run") + results["service_enabled"] = "dry run" + else: + enable_result = run(["systemctl", "enable", service_name], timeout=30) + if enable_result.returncode == 0: + _status_done("enabled") + results["service_enabled"] = True + else: + _status_done("failed") + _fail(f"systemctl enable failed: {enable_result.stderr.strip()}") + results["service_enabled"] = False + + # Start the service — or restart it when the unit changed underneath it. + _status(f"Starting {service_name} service") + is_active = _systemctl_is_active(service_name) + if dry_run: + _status_done("dry run") + results["service_started"] = "dry run" + elif is_active and unit_changed: + restart_result = run(["systemctl", "try-restart", service_name], timeout=60) + if restart_result.returncode == 0: + _status_done("restarted (unit changed)") + results["service_started"] = "restarted" + else: + _status_done("failed") + _fail( + f"systemctl try-restart failed: {restart_result.stderr.strip()} " + f"(check logs with: journalctl -u {service_name} -f)" + ) + results["service_started"] = False + elif is_active: + _status_done("already running") + results["service_started"] = True + else: + start_result = run(["systemctl", "start", service_name], timeout=30) + if start_result.returncode == 0: + _status_done("started") + results["service_started"] = True + else: + _status_done("failed") + _fail( + f"systemctl start failed: {start_result.stderr.strip()} " + f"(check logs with: journalctl -u {service_name} -f)" + ) + results["service_started"] = False + + return results + + +# --------------------------------------------------------------------------- +# 10. Firewall +# --------------------------------------------------------------------------- + + +def setup_firewall(dry_run: bool = False) -> dict[str, Any]: + """Open HTTPS port (443) in firewalld. Idempotent.""" + results: dict[str, Any] = {} + + _status("Checking firewalld") + fw_result = run(["systemctl", "is-active", "--quiet", "firewalld"], timeout=10) + if fw_result.returncode != 0: + _status_done("not running — skipping") + results["firewall_active"] = False + return results + + _status_done("active") + + # Check if https service is already allowed. + _status("Checking HTTPS firewall rule") + list_result = run(["firewall-cmd", "--permanent", "--list-services"], timeout=30) + if list_result.returncode == 0 and "https" in list_result.stdout.split(): + _status_done("already allowed") + results["firewall_configured"] = True + return results + + if dry_run: + _status_done("dry run") + results["firewall_configured"] = "dry run" + return results + + # Add https service. + add_result = run(["firewall-cmd", "--permanent", "--add-service=https"], timeout=30) + if add_result.returncode != 0: + _status_done("failed") + _warn(f"firewall-cmd failed: {add_result.stderr.strip()}") + results["firewall_configured"] = False + return results + + # Reload to apply — a failed reload leaves the rule inactive. + reload_result = run(["firewall-cmd", "--reload"], timeout=30) + if reload_result.returncode == 0: + _status_done("added (permanent)") + results["firewall_configured"] = True + else: + _status_done("failed") + _warn( + f"firewall-cmd --reload failed: {reload_result.stderr.strip()} " + "— rule stored permanently but not active" + ) + results["firewall_configured"] = False + + return results + + +# --------------------------------------------------------------------------- +# 11. Uninstall +# --------------------------------------------------------------------------- + + +def uninstall( + venv_dir: str, + service_name: str, + cert_dir: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Tear down the vLLM deployment. Idempotent. + + Deliberately kept (documented at the end): /opt/vllm with the + HuggingFace cache, the ROCm stack, the firewalld https rule and the + SELinux boolean. + """ + results: dict[str, Any] = {} + + # Stop and disable the systemd service. + _status(f"Stopping {service_name} service") + if _systemctl_is_active(service_name) or _systemctl_is_enabled(service_name): + if dry_run: + _status_done("dry run") + else: + run(["systemctl", "stop", service_name], timeout=60) + run(["systemctl", "disable", service_name], timeout=30) + _status_done("stopped and disabled") + results["service_stopped"] = True + else: + _status_done("not running") + results["service_not_found"] = True + + # Remove systemd unit file. + unit_path = f"/etc/systemd/system/{service_name}.service" + _status(f"Removing {service_name}.service unit file") + if _file_exists(unit_path): + if dry_run: + _status_done("dry run") + else: + os.remove(unit_path) + run(["systemctl", "daemon-reload"], timeout=30) + _status_done("removed") + results["unit_removed"] = True + else: + _status_done("not present") + results["unit_not_found"] = True + + # Remove the API key environment file. + env_path = f"/etc/sysconfig/{service_name}" + _status("Removing API key environment file") + if _file_exists(env_path): + if dry_run: + _status_done("dry run") + else: + os.remove(env_path) + _status_done("removed") + results["env_removed"] = True + else: + _status_done("not present") + results["env_not_found"] = True + + # Remove nginx config. + nginx_conf = "/etc/nginx/conf.d/vllm.conf" + _status("Removing nginx vllm configuration") + if _file_exists(nginx_conf): + if dry_run: + _status_done("dry run") + else: + os.remove(nginx_conf) + run(["systemctl", "reload", "nginx"], timeout=30) + _status_done("removed and nginx reloaded") + results["nginx_removed"] = True + else: + _status_done("not present") + results["nginx_not_found"] = True + + # Remove TLS certificates. + _status("Removing TLS certificates") + if _dir_exists(cert_dir): + if dry_run: + _status_done("dry run") + else: + # Remove individual files first, then the directory. + for fname in ("vllm.key", "vllm.crt"): + fpath = os.path.join(cert_dir, fname) + if _file_exists(fpath): + os.remove(fpath) + try: + os.rmdir(cert_dir) + except OSError: + pass + _status_done("removed") + results["certs_removed"] = True + else: + _status_done("not present") + results["certs_not_found"] = True + + # Remove venv. + _status(f"Removing venv at {venv_dir}") + if _dir_exists(venv_dir): + if dry_run: + _status_done("dry run") + else: + shutil.rmtree(venv_dir) + _status_done("removed") + results["venv_removed"] = True + else: + _status_done("not present") + results["venv_not_found"] = True + + # Remove vllm user. + _status("Removing vllm system user") + if _user_exists("vllm"): + if dry_run: + _status_done("dry run") + else: + userdel_result = run(["userdel", "vllm"], timeout=30) + if userdel_result.returncode == 0: + _status_done("removed") + results["user_removed"] = True + else: + _status_done("failed") + _warn(f"userdel failed: {userdel_result.stderr.strip()}") + results["user_removed"] = False + else: + _status_done("not present") + results["user_not_found"] = True + + # What deliberately persists after uninstall. + print(file=sys.stderr) + _info("Kept on the system (remove manually if unwanted):") + _info(" /opt/vllm — HuggingFace cache with downloaded model weights") + _info(" ROCm stack — via dnf or amdgpu-uninstall") + _info(" firewalld 'https' rule and the SELinux httpd_can_network_connect boolean") + + return results + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def print_summary( + model: str, + port: int, + service_name: str, + os_display: str, + version_id: str, + results: dict[str, Any], + warnings: list[str], + elapsed: float, + dry_run: bool = False, + uninstall_mode: bool = False, + bind_host: str = "", + generated_api_key: str | None = None, +) -> None: + """Print a final summary of all sections to stderr.""" + mode = "Uninstall" if uninstall_mode else ("Dry Run" if dry_run else "Setup") + print(f"\n{BOLD}── {mode} Summary ──{RESET}", file=sys.stderr) + print(f" OS: {os_display} {version_id}", file=sys.stderr) + + if uninstall_mode: + # Uninstall summary. + ur = results.get("uninstall", {}) + for key, label in [ + ("service_stopped", "vLLM service stopped"), + ("unit_removed", "systemd unit removed"), + ("env_removed", "API key environment file removed"), + ("nginx_removed", "nginx config removed"), + ("certs_removed", "TLS certificates removed"), + ("venv_removed", "Python venv removed"), + ("user_removed", "vLLM user removed"), + ]: + if key in ur: + _ok(label) + for key, label in [ + ("service_not_found", "vLLM service: already absent"), + ("unit_not_found", "systemd unit: already absent"), + ("env_not_found", "API key environment file: already absent"), + ("nginx_not_found", "nginx config: already absent"), + ("certs_not_found", "TLS certs: already absent"), + ("venv_not_found", "Python venv: already absent"), + ("user_not_found", "vLLM user: already absent"), + ]: + if key in ur: + _info(label) + else: + # Deploy summary. + _ok(f"Model: {model}") + + sd = results.get("system_deps", {}) + installed = len(sd.get("installed", [])) + skipped = len(sd.get("skipped", [])) + failed_pkgs = len(sd.get("failed", [])) + if dry_run: + _ok(f"System packages: {skipped} present, {installed} would be installed") + else: + _ok(f"System packages: {skipped} already present, {installed} installed") + if failed_pkgs: + _fail(f" {failed_pkgs} package(s) failed to install") + + vu = results.get("vllm_user", {}) + if vu.get("user_created") is True or vu.get("user_exists"): + _ok("vLLM system user: configured") + elif vu.get("user_created") == "dry run": + _info("vLLM system user: would create") + + venv = results.get("venv", {}) + if venv.get("vllm_installed") is True: + ver = venv.get("vllm_version", "") + label = f"vLLM installed ({ver})" if ver else "vLLM installed" + _ok(label) + elif venv.get("vllm_installed") == "dry run": + pf = results.get("preflight", {}) + _info( + f"vLLM: would install vllm=={pf.get('vllm_version', '?')}" + f"+{pf.get('rocm_variant', '?')}" + ) + elif venv.get("venv_exists"): + _warn("vLLM: venv exists but vLLM not installed") + + tls = results.get("tls", {}) + if tls.get("cert_exists") or tls.get("cert_created") is True: + _ok("TLS certificate: configured (self-signed, 365-day validity)") + elif tls.get("cert_created") == "dry run": + _info("TLS: would generate self-signed certificate (SAN from host FQDN)") + + ak = results.get("api_key", {}) + env_state = ak.get("env_file") + if generated_api_key: + _ok(f"API key: stored in /etc/sysconfig/{service_name} (0600 root:vllm)") + print( + f" {YELLOW}{BOLD}API key (shown once — store it securely): " + f"{generated_api_key}{RESET}", + file=sys.stderr, + ) + elif env_state == "exists": + _ok("API key: existing key reused") + elif env_state == "updated": + _ok("API key: updated from --api-key") + elif env_state == "created": + _ok(f"API key: stored in /etc/sysconfig/{service_name} (0600 root:vllm)") + elif env_state == "dry run": + _info(f"API key: would generate/store in /etc/sysconfig/{service_name} (0600)") + elif env_state == "failed": + _fail("API key: could not write the environment file") + + se = results.get("selinux", {}).get("selinux") + if se == "on": + _ok("SELinux: httpd_can_network_connect on") + elif se == "dry run": + _info("SELinux: would set httpd_can_network_connect=1") + elif se == "failed": + _fail("SELinux: failed to set httpd_can_network_connect") + elif se in ("permissive", "disabled"): + _info(f"SELinux: {se} — skipped") + elif se == "absent": + _info("SELinux: not installed — skipped") + + ng = results.get("nginx", {}) + if ng.get("nginx_configured") is True and ng.get("nginx_reloaded") is True: + _ok("nginx: configured and reloaded") + elif ng.get("nginx_configured") is True: + _fail("nginx: config written but reload failed") + elif ng.get("nginx_configured") == "dry run": + _info("nginx: would write config and reload") + + svc = results.get("systemd", {}) + started = svc.get("service_started") + disp = f"[{bind_host}]:{port}" if ":" in bind_host else f"{bind_host}:{port}" + if started is True or started == "restarted": + verb = "restarted with updated unit" if started == "restarted" else "running" + _ok(f"systemd: {service_name} {verb} on {disp}") + _info("First load takes minutes — verify: curl -fk https://localhost/health (retry)") + elif started is False: + _fail(f"systemd: {service_name} failed to start") + elif started == "dry run": + _info(f"systemd: would enable and start {service_name}") + + fw = results.get("firewall", {}) + if fw.get("firewall_active") is False: + _info("Firewall: firewalld not running — skipped") + elif fw.get("firewall_configured") is True: + _ok("Firewall: HTTPS (443) allowed") + elif fw.get("firewall_configured") is False: + _fail("Firewall: failed to allow HTTPS (443)") + elif fw.get("firewall_configured") == "dry run": + _info("Firewall: would allow HTTPS (443)") + + 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 (no interaction — see _prompt_model).""" + parser = argparse.ArgumentParser( + description="Idempotent vLLM deployment for AMD ROCm GPUs", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--model", + type=str, + required=False, + default=None, + help="HuggingFace model ID (if omitted, shows interactive menu)", + ) + parser.add_argument( + "--port", + type=int, + default=8000, + help="vLLM internal port (default: 8000)", + ) + parser.add_argument( + "--tensor-parallel", + type=int, + default=1, + dest="tensor_parallel", + help="Number of GPUs for tensor parallelism (default: 1)", + ) + parser.add_argument( + "--max-model-len", + type=int, + default=4096, + dest="max_model_len", + help="Maximum context length (default: 4096)", + ) + parser.add_argument( + "--gpu-memory-utilization", + type=float, + default=0.90, + dest="gpu_memory_utilization", + help="GPU memory fraction (default: 0.90)", + ) + parser.add_argument( + "--venv-dir", + type=str, + default="/opt/vllm/venv", + dest="venv_dir", + help="Path to Python venv (default: /opt/vllm/venv)", + ) + parser.add_argument( + "--service-name", + type=str, + default="vllm", + dest="service_name", + help="systemd service name (default: vllm)", + ) + parser.add_argument( + "--cert-dir", + type=str, + default="/etc/ssl/vllm", + dest="cert_dir", + help="TLS certificate directory (default: /etc/ssl/vllm)", + ) + parser.add_argument( + "--api-key", + type=str, + default=None, + dest="api_key", + help="API key for the endpoint (default: generate and store in /etc/sysconfig)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview without making changes", + ) + parser.add_argument( + "--uninstall", + action="store_true", + help="Tear down service, venv, nginx config, and certificates", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION}", + ) + return parser.parse_args(argv) + + +def _prompt_model(args: argparse.Namespace) -> None: + """Show the interactive model selection menu; set args.model.""" + print(f"\n{BOLD}Select a model to deploy:{RESET}\n", file=sys.stderr) + for i, name in enumerate(DEFAULT_MODELS, 1): + print(f" {GREEN}{i}{RESET}. {name}", file=sys.stderr) + print(f" {DIM}{len(DEFAULT_MODELS) + 1}. Enter custom model ID{RESET}", file=sys.stderr) + print(file=sys.stderr) + try: + choice = input(f" Choice [{GREEN}1{RESET}]: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.", file=sys.stderr) + sys.exit(130) + if not choice: + choice = "1" + try: + idx = int(choice) + except ValueError: + print(f"{RED}Invalid input.{RESET}", file=sys.stderr) + sys.exit(1) + if 1 <= idx <= len(DEFAULT_MODELS): + args.model = DEFAULT_MODELS[idx - 1] + elif idx == len(DEFAULT_MODELS) + 1: + try: + args.model = input(" Model ID: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.", file=sys.stderr) + sys.exit(130) + if not args.model: + print(f"{RED}Model ID cannot be empty.{RESET}", file=sys.stderr) + sys.exit(1) + else: + print(f"{RED}Invalid choice.{RESET}", file=sys.stderr) + sys.exit(1) + + +def _validate_args(args: argparse.Namespace) -> None: + """Validate CLI arguments; exit with a clear message on invalid input.""" + if not re.match(r"^[A-Za-z0-9_.@-]+$", args.service_name): + _fail(f"Invalid --service-name: {args.service_name!r}") + sys.exit(1) + if args.uninstall: + return + if not MODEL_ID_RE.match(args.model or ""): + _fail( + f"Invalid model ID: {args.model!r} — expected 'org/name' " + "(letters, digits, dot, dash, underscore only)" + ) + sys.exit(1) + if not 1 <= args.port <= 65535 or args.port == 443: + _fail(f"Invalid --port {args.port} — must be 1-65535 and not 443 (nginx)") + sys.exit(1) + if not 0.0 < args.gpu_memory_utilization <= 1.0: + _fail(f"Invalid --gpu-memory-utilization {args.gpu_memory_utilization} — must be in (0, 1]") + sys.exit(1) + if args.max_model_len <= 0: + _fail(f"Invalid --max-model-len {args.max_model_len} — must be positive") + sys.exit(1) + if args.tensor_parallel <= 0: + _fail(f"Invalid --tensor-parallel {args.tensor_parallel} — must be positive") + sys.exit(1) + if args.api_key is not None and (not args.api_key or any(ch.isspace() for ch in args.api_key)): + _fail("Invalid --api-key — must be non-empty and contain no whitespace") + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + """Entry point — parse args, run pre-flight checks, deploy or uninstall.""" + start_time = time.monotonic() + warnings: list[str] = [] + results: dict[str, Any] = {} + failures: list[str] = [] + + # parse_args first: --help/--version must work without root privileges. + args = parse_args() + check_root() + if not args.uninstall and not args.model: + _prompt_model(args) + _validate_args(args) + os_id, os_display, version_id = detect_os() + + # Header. + print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + print( + f"{BOLD} vLLM Deploy v{VERSION} — {os_display} {version_id} (ROCm){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 vLLM 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( + venv_dir=args.venv_dir, + service_name=args.service_name, + cert_dir=args.cert_dir, + dry_run=args.dry_run, + ) + elapsed = time.monotonic() - start_time + print_summary( + model="", + port=args.port, + service_name=args.service_name, + os_display=os_display, + version_id=version_id, + results=results, + warnings=warnings, + elapsed=elapsed, + dry_run=args.dry_run, + uninstall_mode=True, + ) + if results["uninstall"].get("user_removed") is False: + sys.exit(1) + return + + # ── Deploy path ── + bind_host, upstream_host = _detect_loopback() + + try: + # 1. System dependencies (before preflight: provides lspci and curl). + print(f"\n{BOLD}── System Dependencies ──{RESET}", file=sys.stderr) + results["system_deps"] = install_system_deps(dry_run=args.dry_run) + failed = results["system_deps"].get("failed", []) + if failed: + failures.append(f"failed to install packages: {', '.join(failed)}") + + # 2. Pre-flight checks. + print(f"\n{BOLD}── Pre-flight Checks ──{RESET}", file=sys.stderr) + preflight = preflight_checks(os_id=os_id, version_id=version_id, dry_run=args.dry_run) + results["preflight"] = preflight + gpu_count = preflight.get("gpu_count", 0) + _info(f"GPU count: {gpu_count}") + if gpu_count and gpu_count < args.tensor_parallel: + warnings.append( + f"--tensor-parallel={args.tensor_parallel} but only {gpu_count} GPU(s) detected" + ) + + # 3. vLLM system user. + print(f"\n{BOLD}── System User ──{RESET}", file=sys.stderr) + results["vllm_user"] = setup_vllm_user(dry_run=args.dry_run) + if results["vllm_user"].get("user_created") is False: + _fail("Could not create the vllm system user — cannot continue") + sys.exit(1) + + # 4. Python venv + vLLM (fatal on failure — nothing works without it). + print(f"\n{BOLD}── Python Environment ──{RESET}", file=sys.stderr) + results["venv"] = setup_venv( + vllm_version=preflight["vllm_version"], + rocm_variant=preflight["rocm_variant"], + index_url=preflight["wheel_index"], + venv_dir=args.venv_dir, + dry_run=args.dry_run, + ) + if ( + results["venv"].get("venv_created") is False + or results["venv"].get("vllm_installed") is False + ): + _fail("Python environment setup failed — cannot continue") + sys.exit(1) + + # 5. TLS certificate (fatal — nginx cannot start without it). + print(f"\n{BOLD}── TLS Certificate ──{RESET}", file=sys.stderr) + results["tls"] = setup_tls(cert_dir=args.cert_dir, dry_run=args.dry_run) + if results["tls"].get("cert_created") is False: + _fail("TLS certificate setup failed — cannot continue") + sys.exit(1) + + # 6. API key environment file. + print(f"\n{BOLD}── API Key ──{RESET}", file=sys.stderr) + results["api_key"] = setup_api_key( + service_name=args.service_name, + api_key=args.api_key, + dry_run=args.dry_run, + ) + if results["api_key"].get("env_file") == "failed": + _fail("Could not store the API key — cannot continue") + sys.exit(1) + + # 7. SELinux (non-fatal — only relevant on enforcing systems). + print(f"\n{BOLD}── SELinux ──{RESET}", file=sys.stderr) + results["selinux"] = setup_selinux(dry_run=args.dry_run) + if results["selinux"].get("selinux") == "failed": + failures.append("SELinux boolean httpd_can_network_connect not set") + + # 8. nginx. + print(f"\n{BOLD}── nginx ──{RESET}", file=sys.stderr) + results["nginx"] = setup_nginx( + port=args.port, + upstream_host=upstream_host, + cert_dir=args.cert_dir, + dry_run=args.dry_run, + ) + if results["nginx"].get("nginx_reloaded") is False: + failures.append("nginx configuration reload failed") + + # 9. systemd service (model existence probed before writing the unit). + print(f"\n{BOLD}── systemd Service ──{RESET}", file=sys.stderr) + _status(f"Verifying {args.model} on Hugging Face") + hf_status = _hf_model_status(args.model) + if hf_status == "missing": + _status_done("not found") + _fail(f"Model '{args.model}' does not exist on Hugging Face") + sys.exit(1) + if hf_status == "gated": + _status_done("gated") + warnings.append( + f"Model '{args.model}' is gated on Hugging Face — " + "set HF_TOKEN for the vllm user or the first start will fail" + ) + elif hf_status == "unknown": + _status_done("could not verify (offline?)") + else: + _status_done("found") + + results["systemd"] = setup_systemd( + model=args.model, + port=args.port, + host=bind_host, + tensor_parallel=args.tensor_parallel, + max_model_len=args.max_model_len, + gpu_memory_utilization=args.gpu_memory_utilization, + venv_dir=args.venv_dir, + service_name=args.service_name, + dry_run=args.dry_run, + ) + if results["systemd"].get("service_started") is False: + failures.append(f"{args.service_name} service failed to start") + + # 10. Firewall. + print(f"\n{BOLD}── Firewall ──{RESET}", file=sys.stderr) + results["firewall"] = setup_firewall(dry_run=args.dry_run) + if results["firewall"].get("firewall_configured") is False: + failures.append("firewall rule for HTTPS (443) not applied") + + except FileNotFoundError as exc: + print(file=sys.stderr) + _fail(f"Required tool not found: {exc.filename} — install it and re-run") + sys.exit(1) + + elapsed = time.monotonic() - start_time + print_summary( + model=args.model, + port=args.port, + service_name=args.service_name, + os_display=os_display, + version_id=version_id, + results=results, + warnings=warnings, + elapsed=elapsed, + dry_run=args.dry_run, + uninstall_mode=False, + bind_host=bind_host, + generated_api_key=results.get("api_key", {}).get("generated_key"), + ) + + if failures: + _fail(f"Completed with {len(failures)} failed step(s):") + for failure in failures: + _info(f" - {failure}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/workstation-setup.py b/workstation-setup.py new file mode 100644 index 0000000..5f5fed2 --- /dev/null +++ b/workstation-setup.py @@ -0,0 +1,1759 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +# SPDX-License-Identifier: MIT + +"""Idempotent Fedora workstation setup — development tools, editors, and multimedia. + +No external dependencies — stdlib and system tools only. Every operation +checks current state before acting; running the script repeatedly produces +the same result with no errors and no duplicate work. + +Supply-chain posture: +- Bun: pinned release, ZIP downloaded from GitHub and verified against the + release's published SHA-256 (SHASUMS.txt) before installation. +- uv: installed from the official Fedora repositories (dnf). +- Zed: no checksums/signatures are published for install.sh — the version is + pinned and an explicit trust-on-first-use warning is printed. +- Brave: the repo signing key is fingerprint-verified against the official + fingerprints from https://brave.com/signing-keys/ before `rpm --import`. + +Usage: + workstation-setup.py # full setup + workstation-setup.py --dry-run # preview without changes + workstation-setup.py --skip-update # skip system update + workstation-setup.py --skip-rpm # skip RPM package installation + workstation-setup.py --skip-flatpak # skip Flatpak apps + workstation-setup.py --skip-ssh # skip SSH and Git setup + workstation-setup.py --skip-repos # skip adding third-party repos + workstation-setup.py --skip-remove # skip removing pre-installed apps + workstation-setup.py --skip-bun # skip Bun installation + workstation-setup.py --skip-zed # skip Zed editor installation + workstation-setup.py --skip-uv # skip uv installation + workstation-setup.py --skip-rust # skip Rust toolchain + workstation-setup.py --skip-firewall # skip firewall setup + workstation-setup.py --skip-selinux # skip SELinux setup + workstation-setup.py --version +""" + +from __future__ import annotations + +import argparse +import getpass +import hashlib +import os +import platform +import pwd +import shutil +import subprocess +import sys +import tempfile +import time +import zipfile +from typing import Any + +BOLD = "\033[1m" +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +DIM = "\033[2m" +RESET = "\033[0m" +VERSION = "4.1.0" + +# dnf transactions (fresh installs download hundreds of packages) need far +# more than a generic 60-second timeout. +DNF_TIMEOUT = 1800 +DNF_UPDATE_TIMEOUT = 3600 + +# Pinned versions for the curl-based installers (bump deliberately). +BUN_VERSION = "1.3.14" +ZED_VERSION = "0.216.0" + +BRAVE_REPO_URL = "https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo" +BRAVE_KEY_URL = "https://brave-browser-rpm-release.s3.brave.com/brave-core.asc" +BRAVE_REPO_FILE = "/etc/yum.repos.d/brave-browser.repo" + +# Official Brave "Linux Package Repositories - Release Channel" fingerprints, +# from https://brave.com/signing-keys/ (2023 primary key plus the 2025 +# rotations). If Brave rotates again, verification fails loudly — update this +# set from the same page rather than weakening the check. +BRAVE_KEY_FINGERPRINTS: frozenset[str] = frozenset( + { + "DBF1A116C220B8C7164F98230686B78420388257", # Brave Linux Release (2023) + "47D32A74E9A9E013A4B4926C68D513D36A73CD96", # Brave Linux Release (2025, _mrk) + "B2A3DCA350E67256740DF904DE4EC67BE4B0DCA0", # Brave Linux Release (2025, _v2) + } +) + +BUN_BASHRC_LINES: tuple[str, ...] = ( + 'export BUN_INSTALL="$HOME/.bun"', + 'export PATH="$BUN_INSTALL/bin:$PATH"', +) + +# Map os-release ID to display name. +SUPPORTED_OS: dict[str, str] = { + "fedora": "Fedora", + "openeuler": "openEuler", +} + +# Pre-installed packages to remove (skipped if already absent). +REMOVE_PACKAGES: list[str] = [ + "firefox", + "gnome-system-monitor", + "yelp", + "mediawriter", +] + +# RPM packages to install. +RPM_PACKAGES: list[str] = [ + "brave-browser", + "git", + "golang", + "rustup", + "just", + "mediainfo", + "ruby", +] + +# Flatpak applications to install. +FLATPAK_APPS: list[str] = [ + "org.remmina.Remmina", + "org.telegram.desktop", + "fr.handbrake.ghb", + "com.rustdesk.RustDesk", + "org.gimp.GIMP", + "net.nokyan.Resources", + "app.drey.EarTag", + "org.bunkus.mkvtoolnix-gui", + "org.gnome.Firmware", +] + + +# --------------------------------------------------------------------------- +# Progress helpers — write to stderr so stdout stays clean for machine output +# --------------------------------------------------------------------------- + + +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 runners +# --------------------------------------------------------------------------- + + +def run( + cmd: list[str], + timeout: int = 60, + check: bool = False, + use_sudo: bool = False, +) -> subprocess.CompletedProcess[str]: + """Execute a command and return the CompletedProcess. + + Sets LC_ALL=C for reliable English-language output parsing and replaces + undecodable bytes in the output. When *use_sudo* is True, ``sudo`` is + prepended. Timeout and missing-binary conditions are converted into a + failed CompletedProcess (124/127) instead of raising. Raises + subprocess.CalledProcessError when *check* is True and the exit code is + non-zero. + """ + merged_env = {**os.environ, "LANG": "C", "LC_ALL": "C"} + full_cmd = ["sudo"] + cmd if use_sudo else cmd + try: + result = subprocess.run( + full_cmd, + capture_output=True, + text=True, + errors="replace", + timeout=timeout, + env=merged_env, + ) + except subprocess.TimeoutExpired: + result = subprocess.CompletedProcess(full_cmd, 124, "", f"timed out after {timeout}s") + except FileNotFoundError: + result = subprocess.CompletedProcess(full_cmd, 127, "", f"command not found: {full_cmd[0]}") + except OSError as exc: + result = subprocess.CompletedProcess(full_cmd, 126, "", str(exc)) + if check and result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, full_cmd, result.stdout, result.stderr + ) + return result + + +def _run_as_user( + cmd: list[str], + timeout: int = 60, + check: bool = False, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Run a command as the original user (not root) when running via sudo. + + Extra environment variables are injected via env(1) so they survive + sudo's environment reset. + """ + sudo_user = os.environ.get("SUDO_USER") + if sudo_user: + full_cmd = ["sudo", "-u", sudo_user] + if env: + full_cmd.append("env") + full_cmd.extend(f"{key}={value}" for key, value in env.items()) + return run(full_cmd + cmd, timeout=timeout, check=check) + return run(cmd, timeout=timeout, check=check) + + +# --------------------------------------------------------------------------- +# 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("=") + # Strip surrounding quotes if present. + 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 + + +# --------------------------------------------------------------------------- +# Section helpers +# --------------------------------------------------------------------------- + + +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 _have_flatpak(app: str) -> bool: + """Return True if the Flatpak application is already installed (system-wide).""" + result = run(["flatpak", "info", app], check=False) + return result.returncode == 0 + + +def _real_user() -> pwd.struct_passwd | None: + """Return the passwd entry of the real (non-root) user, or None. + + None means "no separate user to drop to" — e.g. the script is running in + a real root shell, or SUDO_USER does not resolve. + """ + sudo_user = os.environ.get("SUDO_USER") + if sudo_user: + try: + return pwd.getpwnam(sudo_user) + except KeyError: + return None + if os.geteuid() == 0: + return None + try: + return pwd.getpwuid(os.geteuid()) + except KeyError: + return None + + +def _real_home() -> str: + """Return the real user's home directory, even when running via sudo.""" + real = _real_user() + if real is not None: + return real.pw_dir + return os.path.expanduser("~") + + +def _chown_user(path: str) -> None: + """Chown *path* to the real user; no-op when there is no separate user.""" + real = _real_user() + if real is not None: + os.chown(path, real.pw_uid, real.pw_gid) + + +def _ensure_user_owns(path: str) -> bool: + """Verify *path* is owned by the real user, fixing root ownership. + + Returns True when ownership is correct (or there is no separate user). + """ + real = _real_user() + if real is None: + return True + try: + if os.stat(path).st_uid == real.pw_uid: + return True + os.chown(path, real.pw_uid, real.pw_gid) + return os.stat(path).st_uid == real.pw_uid + except OSError: + return False + + +def _tool_path(name: str, *home_rel: str) -> str | None: + """Absolute path to *name*: PATH first, then user-home fallback locations.""" + found = shutil.which(name) + if found is not None: + return found + home = _real_home() + for rel in home_rel: + candidate = os.path.join(home, rel) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + +def _probe_version(cmd: list[str], as_user: bool = True) -> str: + """Run a --version probe and return the first line of output, or 'unknown'.""" + runner = _run_as_user if as_user else run + result = runner(cmd) + if result.returncode != 0 or not result.stdout.strip(): + return "unknown" + return result.stdout.strip().split("\n")[0] + + +def _download(url: str, dest: str, timeout: int = 300) -> bool: + """Download *url* to *dest* with curl; return True on success.""" + result = run( + ["curl", "-fsSL", "-o", dest, url], + timeout=timeout, + use_sudo=True, + ) + return result.returncode == 0 + + +def _sha256_file(path: str) -> str: + """Return the hex SHA-256 digest of a file, read in chunks.""" + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _sha256_from_sums(sums_file: str, filename: str) -> str | None: + """Extract the SHA-256 for *filename* from a sha256sum-format file.""" + try: + with open(sums_file, encoding="utf-8") as fh: + for line in fh: + parts = line.split() + if len(parts) == 2 and parts[1].lstrip("*") == filename: + return parts[0].lower() + except OSError: + pass + return None + + +def _asc_fingerprints(key_file: str) -> set[str]: + """Fingerprints of every key in an ASCII-armoured key file (via gpg).""" + if shutil.which("gpg") is None: + return set() + result = run(["gpg", "--show-keys", "--with-colons", key_file]) + if result.returncode != 0: + return set() + fingerprints: set[str] = set() + for line in result.stdout.splitlines(): + parts = line.split(":") + if parts[0] == "fpr" and len(parts) > 9 and parts[9]: + fingerprints.add(parts[9].upper()) + return fingerprints + + +# --------------------------------------------------------------------------- +# 1. System update +# --------------------------------------------------------------------------- + + +def system_update(dry_run: bool = False) -> dict[str, Any]: + """Update the system via dnf. + + Returns a dict with summary info: {'updated', 'skipped', 'would_update', + 'error'}. + """ + info: dict[str, Any] = { + "updated": False, + "skipped": False, + "would_update": False, + "error": False, + } + + _status("Checking for system updates") + check_result = run(["dnf", "check-update"], timeout=300, use_sudo=True) + # dnf check-update: 0 = no updates, 100 = updates available, else = error. + if check_result.returncode == 0: + _status_done("already up to date") + info["skipped"] = True + return info + if check_result.returncode != 100: + _status_done("check failed") + _fail(f"dnf check-update failed: {check_result.stderr.strip() or 'unknown error'}") + info["error"] = True + return info + + _status_done("updates available") + + if dry_run: + _info("Would run: dnf update -y") + info["would_update"] = True + return info + + _status("Applying system updates") + update_result = run(["dnf", "update", "-y"], timeout=DNF_UPDATE_TIMEOUT, use_sudo=True) + if update_result.returncode == 0: + _status_done() + info["updated"] = True + else: + _status_done("failed") + _fail("System update returned non-zero exit code") + info["error"] = True + return info + + +# --------------------------------------------------------------------------- +# 2. Remove pre-installed apps +# --------------------------------------------------------------------------- + + +def remove_packages(dry_run: bool = False) -> dict[str, Any]: + """Remove unwanted pre-installed packages. + + Returns a dict with 'removed' (list) and 'skipped' (list). + """ + removed: list[str] = [] + skipped: list[str] = [] + + for pkg in REMOVE_PACKAGES: + _status(f"Checking {pkg}") + if not _rpm_installed(pkg): + _status_done("not installed, skipping") + skipped.append(pkg) + continue + + if dry_run: + _status_done("would remove") + removed.append(pkg) + else: + result = run(["dnf", "remove", "-y", pkg], timeout=DNF_TIMEOUT, use_sudo=True) + if result.returncode == 0: + _status_done("removed") + removed.append(pkg) + else: + _status_done("failed") + _fail(f"Failed to remove {pkg}") + + return {"removed": removed, "skipped": skipped} + + +# --------------------------------------------------------------------------- +# 3. Third-party RPM repos +# --------------------------------------------------------------------------- + + +def _import_brave_key() -> bool: + """Download, fingerprint-verify, and import Brave's RPM signing key.""" + if shutil.which("gpg") is None: + _fail("gpg not found — cannot verify the Brave signing key") + return False + with tempfile.TemporaryDirectory() as tmp: + key_file = os.path.join(tmp, "brave-core.asc") + if not _download(BRAVE_KEY_URL, key_file, timeout=60): + _fail("Failed to download the Brave signing key") + return False + fingerprints = _asc_fingerprints(key_file) + if not fingerprints: + _fail("No keys found in the downloaded Brave key file") + return False + if not fingerprints.issubset(BRAVE_KEY_FINGERPRINTS): + _fail( + "Brave signing key fingerprint mismatch — expected one of: " + + ", ".join(sorted(BRAVE_KEY_FINGERPRINTS)) + + "; got: " + + ", ".join(sorted(fingerprints)) + ) + return False + _ok("Brave signing key fingerprint verified") + result = run(["rpm", "--import", key_file], use_sudo=True) + if result.returncode != 0: + _fail("rpm --import failed for the Brave signing key") + return False + return True + + +def _add_brave_repo() -> bool: + """Add the Brave repo using the correct config-manager syntax (dnf5/dnf4).""" + if shutil.which("dnf5") is not None: + cmd = ["dnf", "config-manager", "addrepo", f"--from-repofile={BRAVE_REPO_URL}"] + else: + # dnf4 (Fedora <= 40, openEuler) has no 'addrepo' subcommand. + cmd = ["dnf", "config-manager", "--add-repo", BRAVE_REPO_URL] + result = run(cmd, timeout=120, use_sudo=True) + if result.returncode != 0: + _fail( + "Failed to add the Brave repo (on dnf4 systems this requires " + "the dnf-plugins-core package)" + ) + return False + return True + + +def setup_repos(dry_run: bool = False) -> dict[str, Any]: + """Add third-party RPM repositories. + + Returns a dict with 'added' (list) and 'skipped' (list). + """ + added: list[str] = [] + skipped: list[str] = [] + + # --- Brave Browser --- + repo_name = "brave-browser" + _status(f"Checking {repo_name} repo") + if os.path.exists(BRAVE_REPO_FILE): + _status_done("already present") + skipped.append(repo_name) + elif dry_run: + _status_done("would verify + import GPG key, would add repo") + added.append(repo_name) + else: + if _import_brave_key() and _add_brave_repo(): + _status_done("added") + added.append(repo_name) + else: + _status_done("failed") + + return {"added": added, "skipped": skipped} + + +# --------------------------------------------------------------------------- +# 4. RPM packages +# --------------------------------------------------------------------------- + + +def install_rpm_packages(dry_run: bool = False) -> dict[str, Any]: + """Install RPM packages, skipping any that are already present. + + Installs in a single dnf transaction, falling back to per-package + installs if the batch fails. Returns a dict with 'installed' (list), + 'skipped' (list), 'failed' (list). + """ + installed: list[str] = [] + skipped: list[str] = [] + failed: list[str] = [] + to_install: list[str] = [] + + _status("Checking RPM packages") + for pkg in RPM_PACKAGES: + if _rpm_installed(pkg): + skipped.append(pkg) + else: + to_install.append(pkg) + + already = len(skipped) + missing = len(to_install) + total = len(RPM_PACKAGES) + _status_done(f"{already}/{total} already installed") + + if not to_install: + _ok("All RPM packages already present") + return {"installed": installed, "skipped": skipped, "failed": failed} + + _info(f"Installing {missing} package(s): {' '.join(to_install)}") + + if dry_run: + for pkg in to_install: + _info(f" Would install: {pkg}") + installed.append(pkg) + return {"installed": installed, "skipped": skipped, "failed": failed} + + _status(f"Installing {missing} package(s)") + batch = run( + ["dnf", "install", "-y", *to_install], + timeout=DNF_TIMEOUT, + use_sudo=True, + ) + if batch.returncode == 0: + _status_done() + installed.extend(to_install) + return {"installed": installed, "skipped": skipped, "failed": failed} + + _status_done("batch failed, retrying one by one") + for pkg in to_install: + _status(f"Installing {pkg}") + result = run(["dnf", "install", "-y", pkg], timeout=DNF_TIMEOUT, use_sudo=True) + if result.returncode == 0: + _status_done() + installed.append(pkg) + else: + _status_done("failed") + _fail(f"Failed to install {pkg}") + failed.append(pkg) + + return {"installed": installed, "skipped": skipped, "failed": failed} + + +# --------------------------------------------------------------------------- +# 5. Dev tool installers (Bun, Zed, uv) +# --------------------------------------------------------------------------- + + +def _add_bashrc_lines(lines: tuple[str, ...], comment: str) -> bool: + """Append export lines to ~/.bashrc unless each is already present. + + Matches on the export line content, not on the surrounding block, so a + block previously written by an upstream installer is recognised too. + Returns True when a block was appended. + """ + home = _real_home() + bashrc = os.path.join(home, ".bashrc") + if not os.path.exists(bashrc): + return False + try: + with open(bashrc, encoding="utf-8") as fh: + content = fh.read() + except OSError: + return False + if all(line in content for line in lines): + return False + try: + with open(bashrc, "a", encoding="utf-8") as fh: + fh.write(f"\n# {comment}\n" + "\n".join(lines) + "\n") + return True + except OSError: + return False + + +def _bun_target() -> str: + """Bun release artifact name for this machine (mirrors the install script).""" + machine = platform.machine() + if machine in ("aarch64", "arm64"): + return "bun-linux-aarch64" + target = "bun-linux-x64" + try: + with open("/proc/cpuinfo", encoding="utf-8") as fh: + if "avx2" not in fh.read(): + target = "bun-linux-x64-baseline" + except OSError: + pass + return target + + +def _install_bun_verified() -> str | None: + """Download pinned Bun, verify its SHA-256, install to ~/.bun/bin. + + Returns the installed binary path on success, None on failure. + """ + target = _bun_target() + zip_name = f"{target}.zip" + base_url = f"https://github.com/oven-sh/bun/releases/download/bun-v{BUN_VERSION}" + + with tempfile.TemporaryDirectory() as tmp: + zip_path = os.path.join(tmp, zip_name) + sums_path = os.path.join(tmp, "SHASUMS.txt") + if not _download(f"{base_url}/{zip_name}", zip_path): + _fail(f"Failed to download {zip_name} (bun v{BUN_VERSION})") + return None + if not _download(f"{base_url}/SHASUMS.txt", sums_path, timeout=60): + _fail("Failed to download Bun SHASUMS.txt") + return None + + expected = _sha256_from_sums(sums_path, zip_name) + if expected is None: + _fail(f"No checksum for {zip_name} in SHASUMS.txt") + return None + actual = _sha256_file(zip_path) + if actual != expected: + _fail(f"SHA-256 mismatch for {zip_name}: expected {expected}, got {actual}") + return None + _info("SHA-256 checksum verified") + + home = _real_home() + bin_dir = os.path.join(home, ".bun", "bin") + made = _run_as_user(["install", "-d", "-m", "755", bin_dir]) + if made.returncode != 0: + _fail(f"Failed to create {bin_dir}") + return None + + try: + with zipfile.ZipFile(zip_path) as archive: + data = archive.read(f"{target}/bun") + except (OSError, KeyError, zipfile.BadZipFile) as exc: + _fail(f"Failed to extract bun from {zip_name}: {exc}") + return None + + bun_path = os.path.join(bin_dir, "bun") + try: + with open(bun_path, "wb") as fh: + fh.write(data) + os.chmod(bun_path, 0o755) + _chown_user(bun_path) + except OSError as exc: + _fail(f"Failed to install bun to {bun_path}: {exc}") + return None + return bun_path + + +def setup_bun(dry_run: bool = False) -> dict[str, Any]: + """Install Bun from the pinned, checksum-verified GitHub release. + + Returns a dict with 'installed' (bool) and 'version' (str). + """ + info: dict[str, Any] = {"installed": False, "version": "", "planned": False} + + _status("Checking Bun") + bun = _tool_path("bun", ".bun/bin/bun") + if bun is not None: + info["version"] = _probe_version([bun, "--version"]) + _status_done(info["version"]) + info["installed"] = True + return info + + _status_done("not installed") + + if dry_run: + _info( + f"Would download bun v{BUN_VERSION} + SHASUMS.txt from GitHub, " + "verify SHA-256, and install to ~/.bun/bin" + ) + info["planned"] = True + return info + + _status(f"Installing Bun v{BUN_VERSION}") + bun_path = _install_bun_verified() + if bun_path is None: + _status_done("failed") + return info + _status_done() + info["installed"] = True + if _add_bashrc_lines(BUN_BASHRC_LINES, "bun"): + _info("Added Bun PATH to ~/.bashrc") + info["version"] = _probe_version([bun_path, "--version"]) + return info + + +def setup_zed(dry_run: bool = False) -> dict[str, Any]: + """Install Zed via the official install script with a pinned version. + + Zed publishes no checksums or signatures for install.sh; the download is + trust-on-first-use over HTTPS and a warning is printed accordingly. + Returns a dict with 'installed' (bool) and 'version' (str). + """ + info: dict[str, Any] = {"installed": False, "version": "", "planned": False} + + _status("Checking Zed") + zed = _tool_path("zed", ".local/bin/zed") + if zed is not None: + info["version"] = _probe_version([zed, "--version"]) + _status_done(info["version"]) + info["installed"] = True + return info + + _status_done("not installed") + + if dry_run: + _info(f"Would run the Zed install script with ZED_VERSION={ZED_VERSION}") + info["planned"] = True + return info + + _warn( + "Zed publishes no checksums/signatures for install.sh — " + "trusting https://zed.dev over HTTPS (version pinned)" + ) + _status(f"Installing Zed {ZED_VERSION}") + with tempfile.TemporaryDirectory() as tmp: + script = os.path.join(tmp, "zed-install.sh") + if not _download("https://zed.dev/install.sh", script, timeout=60): + _status_done("download failed") + _fail("Failed to download the Zed install script") + return info + # The script runs as the real user, who cannot enter root's 0700 tmpdir. + os.chmod(tmp, 0o755) + os.chmod(script, 0o644) + result = _run_as_user( + ["sh", script], + timeout=300, + env={"ZED_VERSION": ZED_VERSION}, + ) + if result.returncode == 0: + _status_done() + info["installed"] = True + zed_path = _tool_path("zed", ".local/bin/zed") + if zed_path is not None: + info["version"] = _probe_version([zed_path, "--version"]) + else: + _status_done("failed") + _fail("Zed installation returned non-zero exit code") + + return info + + +def setup_uv(dry_run: bool = False) -> dict[str, Any]: + """Install uv from the official Fedora repositories. + + Returns a dict with 'installed' (bool) and 'version' (str). + """ + info: dict[str, Any] = {"installed": False, "version": "", "planned": False} + + _status("Checking uv") + uv = _tool_path("uv", ".local/bin/uv") + if uv is not None: + info["version"] = _probe_version([uv, "--version"]) + _status_done(info["version"]) + info["installed"] = True + return info + + _status_done("not installed") + + if dry_run: + _info("Would install uv via: dnf install -y uv (official Fedora package)") + info["planned"] = True + return info + + _status("Installing uv") + result = run(["dnf", "install", "-y", "uv"], timeout=DNF_TIMEOUT, use_sudo=True) + if result.returncode == 0: + _status_done() + info["installed"] = True + uv_path = _tool_path("uv", ".local/bin/uv") + if uv_path is not None: + info["version"] = _probe_version([uv_path, "--version"], as_user=False) + else: + _status_done("failed") + _fail("uv installation via dnf returned non-zero exit code") + + return info + + +# --------------------------------------------------------------------------- +# 6. Rust toolchain +# --------------------------------------------------------------------------- + + +def setup_rust(dry_run: bool = False) -> dict[str, Any]: + """Install the Rust toolchain via rustup-init if not present. + + Returns a dict with 'installed' (bool) and 'version' (str). + """ + info: dict[str, Any] = {"installed": False, "version": "", "planned": False} + + _status("Checking Rust") + rustc = _tool_path("rustc", ".cargo/bin/rustc") + if rustc is not None: + info["version"] = _probe_version([rustc, "--version"]) + _status_done(info["version"]) + info["installed"] = True + return info + + _status_done("not installed") + + if dry_run: + _info("Would run: rustup-init -y") + info["planned"] = True + return info + + rustup_init = _tool_path("rustup-init") + if rustup_init is None: + _fail("rustup-init not found — install the 'rustup' RPM first (or do not pass --skip-rpm)") + return info + + _status("Installing Rust toolchain") + result = _run_as_user([rustup_init, "-y"], timeout=300) + if result.returncode == 0: + rustc_path = os.path.join(_real_home(), ".cargo/bin/rustc") + info["version"] = _probe_version([rustc_path, "--version"]) + _status_done(info["version"]) + info["installed"] = True + else: + _status_done("failed") + _fail("Rust installation returned non-zero exit code") + + return info + + +# --------------------------------------------------------------------------- +# 7. Flatpak apps +# --------------------------------------------------------------------------- + + +def _flatpak_remote_names() -> set[str]: + """Names of configured system-wide Flatpak remotes (exact match, no substrings).""" + result = run(["flatpak", "remotes"], check=False) + names: set[str] = set() + for line in result.stdout.splitlines(): + tokens = line.split() + if tokens and tokens[0] != "Name": # skip the table header + names.add(tokens[0]) + return names + + +def setup_flatpak(dry_run: bool = False) -> dict[str, Any]: + """Set up Flathub remote and install Flatpak applications. + + Installs are system-wide (run as root). Caveat: apps previously installed + per-user (e.g. via GNOME Software) live in the user installation and are + NOT detected here — they would be duplicated system-wide. + + Returns a dict with 'remote_added' (bool), 'installed' (list), + 'skipped' (list), and 'failed' (list). + """ + installed: list[str] = [] + skipped: list[str] = [] + failed: list[str] = [] + remote_added = False + + # --- Ensure Flathub remote --- + _status("Checking Flathub remote") + if "flathub" in _flatpak_remote_names(): + _status_done("already present") + elif dry_run: + _status_done("would add") + remote_added = True + else: + result = run( + [ + "flatpak", + "remote-add", + "--if-not-exists", + "flathub", + "https://flathub.org/repo/flathub.flatpakrepo", + ], + timeout=120, + use_sudo=True, + ) + if result.returncode == 0: + _status_done("added") + remote_added = True + else: + _status_done("failed") + _fail("Failed to add Flathub remote") + + # --- Install each app --- + for app in FLATPAK_APPS: + _status(f"Checking {app}") + if _have_flatpak(app): + _status_done("already installed") + skipped.append(app) + continue + + if dry_run: + _status_done("would install") + installed.append(app) + else: + result = run( + ["flatpak", "install", "-y", "flathub", app], + timeout=300, + use_sudo=True, + ) + if result.returncode == 0: + _status_done("installed") + installed.append(app) + else: + _status_done("failed") + _fail(f"Failed to install {app}") + failed.append(app) + + return { + "remote_added": remote_added, + "installed": installed, + "skipped": skipped, + "failed": failed, + } + + +# --------------------------------------------------------------------------- +# 8. SSH + Git +# --------------------------------------------------------------------------- + + +def _prompt_ssh_passphrase() -> str | None: + """Prompt for an SSH key passphrase. Returns None to abort the section. + + Empty is allowed but loudly warned about; non-interactive runs get an + empty passphrase with an equally loud warning. + """ + if not sys.stdin.isatty(): + _warn("Non-interactive run — the SSH key will be generated WITHOUT a passphrase") + return "" + try: + first = getpass.getpass("SSH key passphrase (empty = no passphrase): ") + second = getpass.getpass("Confirm passphrase: ") + except (EOFError, KeyboardInterrupt): + print(file=sys.stderr) + return None + if first != second: + _fail("Passphrases do not match") + return None + if not first: + _warn( + "Empty passphrase — anyone who can read the key file can use it. " + "Consider re-generating with a passphrase and using ssh-agent." + ) + return first + + +def setup_ssh(dry_run: bool = False) -> dict[str, Any]: + """Set up SSH keys and Git configuration for Codeberg — interactively. + + Everything is created as the real user (via sudo -u / chown), never + left root-owned in the user's home. Returns a dict with 'skipped', + 'key_generated', 'key_existed', 'config_updated', 'git_configured', + and 'dry_run'. + """ + info: dict[str, Any] = { + "skipped": False, + "key_generated": False, + "key_existed": False, + "config_updated": False, + "git_configured": False, + "dry_run": dry_run, + } + ssh_dir = os.path.join(_real_home(), ".ssh") + key_path = os.path.join(ssh_dir, "id_ed25519") + config_path = os.path.join(ssh_dir, "config") + pub_path = key_path + ".pub" + + if dry_run: + _info("Would prompt for Git identity (name, email)") + _info("Would generate an ed25519 SSH key if missing") + _info("Would prompt for a key passphrase (empty only with a warning)") + _info("Would configure ~/.ssh/config for codeberg.org") + _info("Would set git user.name, user.email and the codeberg insteadOf rule") + return info + + # Ask whether to set up SSH at all + try: + answer = input("Set up Codeberg SSH + Git? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print(file=sys.stderr) + _info("SSH + Git: skipped") + info["skipped"] = True + return info + if answer in ("n", "no"): + _info("SSH + Git: skipped") + info["skipped"] = True + return info + + # Ask for user details with defaults + try: + user_name = input("Git user name [Petr Balvín]: ").strip() + except (EOFError, KeyboardInterrupt): + print(file=sys.stderr) + _info("SSH + Git: skipped") + info["skipped"] = True + return info + if not user_name: + user_name = "Petr Balvín" + + try: + user_email = input("Git email [opensource@petrbalvin.org]: ").strip() + except (EOFError, KeyboardInterrupt): + print(file=sys.stderr) + _info("SSH + Git: skipped") + info["skipped"] = True + return info + if not user_email: + user_email = "opensource@petrbalvin.org" + + info["key_existed"] = os.path.exists(key_path) + + # --- Generate ED25519 SSH key (as the real user) --- + _status("Checking SSH key") + if info["key_existed"]: + _status_done("already exists") + else: + passphrase = _prompt_ssh_passphrase() + if passphrase is None: + info["skipped"] = True + return info + made = _run_as_user(["install", "-d", "-m", "700", ssh_dir]) + if made.returncode != 0: + _status_done("failed") + _fail(f"Failed to create {ssh_dir}") + return info + # NOTE: the passphrase passes through the process argv briefly + # (visible to the user and root in ps) — standard for unattended + # ssh-keygen; interactive alternative would block automation. + result = _run_as_user( + [ + "ssh-keygen", + "-t", + "ed25519", + "-C", + user_email, + "-N", + passphrase, + "-f", + key_path, + ], + ) + if result.returncode == 0: + _status_done("generated") + info["key_generated"] = True + else: + _status_done("failed") + _fail("Failed to generate SSH key") + + # --- Configure ~/.ssh/config --- + host_entry = ( + f"Host codeberg.org\n User git\n IdentityFile {key_path}\n IdentitiesOnly yes\n" + ) + + _status("Checking SSH config") + made = _run_as_user(["install", "-d", "-m", "700", ssh_dir]) + if made.returncode != 0: + _status_done("failed") + _fail(f"Failed to create {ssh_dir}") + return info + + existing = "" + if os.path.exists(config_path): + try: + with open(config_path, encoding="utf-8") as f: + existing = f.read() + except OSError: + existing = "" + + if "Host codeberg.org" in existing: + _status_done("already configured") + else: + try: + with open(config_path, "a", encoding="utf-8") as f: + f.write("\n" + host_entry if existing else host_entry) + os.chmod(config_path, 0o600) + _chown_user(config_path) + _status_done("updated") + info["config_updated"] = True + except OSError: + _status_done("failed") + _fail("Failed to update SSH config") + + # --- Verify nothing in ~/.ssh ended up root-owned --- + for path in (ssh_dir, key_path, pub_path, config_path): + if os.path.exists(path) and not _ensure_user_owns(path): + _fail(f"{path} is root-owned and could not be fixed") + + # --- Git SSH configuration (as the real user) --- + git_configs = { + "user.name": user_name, + "user.email": user_email, + 'url."git@codeberg.org:".insteadOf': "https://codeberg.org/", + } + + for key, value in git_configs.items(): + _status(f"Checking git config {key}") + result = _run_as_user(["git", "config", "--global", "--get", key]) + if result.returncode == 0 and result.stdout.strip() == value: + _status_done("already set") + else: + result = _run_as_user(["git", "config", "--global", key, value]) + if result.returncode == 0: + _status_done("set") + info["git_configured"] = True + else: + _status_done("failed") + _fail(f"Failed to set git config {key}") + + # --- Show public key --- + if info["key_generated"] or info["key_existed"]: + try: + with open(pub_path, encoding="utf-8") as f: + pub_key = f.read().strip() + print(file=sys.stderr) + print( + f"\n{BOLD} Your SSH public key" + f" (add to https://codeberg.org/settings/keys):{RESET}\n", + file=sys.stderr, + ) + print(f" {GREEN}{pub_key}{RESET}", file=sys.stderr) + print(file=sys.stderr) + except OSError: + _warn("SSH public key not found") + + return info + + +# --------------------------------------------------------------------------- +# 9. Firewall +# --------------------------------------------------------------------------- + + +def setup_firewall(dry_run: bool = False) -> dict[str, Any]: + """Ensure firewalld is installed, enabled, and running. + + Unlike server-setup, does NOT change the default zone — the + workstation default (FedoraWorkstation) is left as-is. + + Returns a dict with summary of what was configured; 'enabled' and + 'running' always reflect the actual probed state. + """ + info: dict[str, Any] = { + "installed": False, + "enabled": False, + "running": False, + } + + # --- Install firewalld if missing --- + _status("Checking firewalld") + if not _rpm_installed("firewalld"): + _status_done("not installed") + if dry_run: + _info("Would install: firewalld") + else: + result = run( + ["dnf", "install", "-y", "firewalld"], + timeout=DNF_TIMEOUT, + use_sudo=True, + ) + if result.returncode == 0: + _ok("Installed firewalld") + info["installed"] = True + else: + _fail("Failed to install firewalld") + return info + else: + _status_done("installed") + info["installed"] = True + + # --- Enable and start --- + _status("Enabling firewalld service") + enabled = run(["systemctl", "is-enabled", "firewalld.service"], check=False) + if enabled.returncode != 0: + if dry_run: + _status_done("would enable") + else: + result = run(["systemctl", "enable", "firewalld.service"], use_sudo=True) + if result.returncode == 0: + _status_done("enabled") + else: + _status_done("failed") + _fail(f"Failed to enable firewalld: {result.stderr.strip()}") + else: + _status_done("already enabled") + + _status("Starting firewalld service") + active = run(["systemctl", "is-active", "firewalld.service"], check=False) + if active.returncode != 0: + if dry_run: + _status_done("would start") + else: + result = run(["systemctl", "start", "firewalld.service"], use_sudo=True) + if result.returncode == 0: + _status_done("started") + else: + _status_done("failed") + _fail(f"Failed to start firewalld: {result.stderr.strip()}") + else: + _status_done("already running") + + # Reflect the real probed state, never assumed success. + if not dry_run: + info["enabled"] = ( + run(["systemctl", "is-enabled", "firewalld.service"], check=False).returncode == 0 + ) + info["running"] = ( + run(["systemctl", "is-active", "firewalld.service"], check=False).returncode == 0 + ) + + return info + + +# --------------------------------------------------------------------------- +# 10. SELinux +# --------------------------------------------------------------------------- + + +def setup_selinux(dry_run: bool = False) -> dict[str, Any]: + """Ensure SELinux is enforcing. + + A Disabled SELinux cannot be switched at runtime — only the config file + is fixed and a reboot warning is printed (setenforce always fails there). + + Returns a dict with summary of changes. + """ + info: dict[str, Any] = { + "mode_changed": False, + "config_changed": False, + "current_mode": "unknown", + "reboot_required": False, + } + + # --- Check current mode --- + _status("Checking SELinux mode") + mode_result = run(["getenforce"], check=False) + current_mode = mode_result.stdout.strip() + info["current_mode"] = current_mode + _status_done(current_mode) + + if current_mode == "Permissive": + if dry_run: + _info("Would set SELinux to enforcing mode") + else: + _status("Setting SELinux to enforcing") + result = run(["setenforce", "1"], use_sudo=True) + if result.returncode == 0: + _status_done() + info["mode_changed"] = True + else: + _status_done("failed") + _fail(f"setenforce 1 failed: {result.stderr.strip()}") + elif current_mode == "Disabled": + _warn( + "SELinux is disabled — it cannot be enabled at runtime. " + "Setting SELINUX=enforcing in the config; a REBOOT is required." + ) + info["reboot_required"] = True + + # --- Ensure SELINUX=enforcing in config --- + _status("Checking /etc/selinux/config") + try: + with open("/etc/selinux/config", encoding="utf-8") as fh: + lines = fh.read().splitlines() + + has_enforcing = False + for line in lines: + stripped = line.strip() + if stripped.startswith("SELINUX=") and not stripped.startswith("#"): + if stripped.split("=", 1)[1].strip().lower() == "enforcing": + has_enforcing = True + break + + if not has_enforcing: + if dry_run: + _status_done("would update to SELINUX=enforcing") + else: + new_lines: list[str] = [] + found = False + for line in lines: + stripped = line.strip() + if stripped.startswith("SELINUX=") and not stripped.startswith("#"): + if not found: + new_lines.append("SELINUX=enforcing") + found = True + # Drop any duplicate active SELINUX= lines. + else: + new_lines.append(line) + if not found: + new_lines.append("SELINUX=enforcing") + with open("/etc/selinux/config", "w", encoding="utf-8") as fh: + fh.write("\n".join(new_lines) + "\n") + _status_done("updated to enforcing") + info["config_changed"] = True + else: + _status_done("already enforcing") + except OSError as exc: + _status_done("error") + _warn(f"Cannot read/write /etc/selinux/config: {exc}") + + return info + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def print_summary( + os_display: str, + version_id: str, + results: dict[str, Any], + warnings: list[str], + elapsed: float, + dry_run: bool = False, +) -> None: + """Print a final summary of all sections to stderr. + + In dry-run mode nothing is phrased as accomplished — only "would …". + """ + print(f"\n{BOLD}══ Setup Summary ══{RESET}", file=sys.stderr) + print(f" OS: {os_display} {version_id}", file=sys.stderr) + + # System update + ru = results.get("update", {}) + if ru.get("error"): + _fail("System update failed") + elif ru.get("would_update"): + _info("Would apply system updates") + elif ru.get("updated"): + _ok("System updated") + elif ru.get("skipped"): + _info("System already up to date") + elif ru: + _info("System update skipped") + + # Remove packages + rr = results.get("remove", {}) + if rr: + removed = len(rr.get("removed", [])) + r_skipped = len(rr.get("skipped", [])) + if dry_run: + _info(f"Remove: would remove {removed}, {r_skipped} already absent") + elif removed: + _ok(f"Removed: {removed} package(s), {r_skipped} already absent") + else: + _info(f"Remove: {r_skipped} package(s) already absent") + + # Repos + rp = results.get("repos", {}) + if rp: + r_added = len(rp.get("added", [])) + rp_skipped = len(rp.get("skipped", [])) + if dry_run: + _info(f"Repos: would add {r_added}, {rp_skipped} already present") + else: + _ok(f"Repos: {r_added} added, {rp_skipped} already present") + + # RPM packages + rpkg = results.get("rpm", {}) + if rpkg: + r_installed = len(rpkg.get("installed", [])) + r_skip = len(rpkg.get("skipped", [])) + r_fail = len(rpkg.get("failed", [])) + if dry_run: + _info(f"RPM packages: would install {r_installed}, {r_skip} already present") + else: + _ok(f"RPM packages: {r_skip} already present, {r_installed} installed") + if r_fail: + _fail(f" {r_fail} package(s) failed to install") + + # Curl-based / packaged dev tools + for section, label in (("bun", "Bun"), ("zed", "Zed"), ("uv", "uv"), ("rust", "Rust")): + rt = results.get(section, {}) + if not rt: + continue + if rt.get("version"): + _ok(f"{label}: {rt['version']}") + elif rt.get("planned"): + _info(f"{label}: would be installed") + elif rt.get("installed"): + _ok(f"{label}: installed") + else: + _info(f"{label}: not installed") + + # Flatpak + rf = results.get("flatpak", {}) + if rf: + f_installed = len(rf.get("installed", [])) + f_skipped = len(rf.get("skipped", [])) + f_failed = len(rf.get("failed", [])) + if dry_run: + _info(f"Flatpak: would install {f_installed}, {f_skipped} already present") + else: + _ok(f"Flatpak: {f_skipped} already present, {f_installed} installed") + if f_failed: + _fail(f" {f_failed} app(s) failed to install") + + # SSH + Git + rsh = results.get("ssh", {}) + if rsh: + if rsh.get("skipped"): + _info("SSH + Git: skipped") + elif rsh.get("dry_run"): + _info("SSH + Git: would be configured (key, ssh config, git identity)") + else: + key_status = ( + "generated" + if rsh.get("key_generated") + else ("exists" if rsh.get("key_existed") else "not set up") + ) + config_status = "updated" if rsh.get("config_updated") else "OK" + git_status = "configured" if rsh.get("git_configured") else "OK" + _ok(f"SSH + Git: key {key_status}, config {config_status}, git {git_status}") + + # Firewall + rfw = results.get("firewall", {}) + if rfw: + if dry_run: + _info("Firewall: would ensure firewalld is installed, enabled and running") + elif rfw.get("running"): + _ok("Firewall: installed & running") + else: + _fail("Firewall: not running") + + # SELinux + rs = results.get("selinux", {}) + if rs: + mode = rs.get("current_mode", "?") + if rs.get("reboot_required"): + _warn(f"SELinux: mode={mode} — config set to enforcing, REBOOT required") + else: + _ok(f"SELinux: mode={mode}") + + 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 workstation setup for Fedora", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be done without making changes", + ) + parser.add_argument( + "--skip-update", + action="store_true", + help="Skip system update", + ) + parser.add_argument( + "--skip-rpm", + action="store_true", + help="Skip RPM package installation", + ) + parser.add_argument( + "--skip-flatpak", + action="store_true", + help="Skip Flatpak apps", + ) + parser.add_argument( + "--skip-ssh", + action="store_true", + help="Skip SSH and Git setup", + ) + parser.add_argument( + "--skip-repos", + action="store_true", + help="Skip adding third-party repos", + ) + parser.add_argument( + "--skip-remove", + action="store_true", + help="Skip removing pre-installed apps", + ) + parser.add_argument( + "--skip-bun", + action="store_true", + help="Skip Bun installation", + ) + parser.add_argument( + "--skip-zed", + action="store_true", + help="Skip Zed editor installation", + ) + parser.add_argument( + "--skip-uv", + action="store_true", + help="Skip uv installation", + ) + parser.add_argument( + "--skip-rust", + action="store_true", + help="Skip Rust toolchain installation", + ) + parser.add_argument( + "--skip-firewall", + action="store_true", + help="Skip firewall setup", + ) + parser.add_argument( + "--skip-selinux", + action="store_true", + help="Skip SELinux setup", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION}", + ) + return parser.parse_args(argv) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + """Entry point — parse args, detect OS, then run enabled sections.""" + start_time = time.monotonic() + warnings: list[str] = [] + results: dict[str, Any] = {} + + # Parsed first so --help/--version work on any OS, root or not. + args = parse_args() + + os_id, os_display, version_id = detect_os() + + # Header + print(file=sys.stderr) + print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + print( + f"{BOLD} Workstation Setup 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, + ) + print(file=sys.stderr) + + # 1. System update + print(f"\n{BOLD}── System Update ──{RESET}", file=sys.stderr) + if not args.skip_update: + results["update"] = system_update(dry_run=args.dry_run) + else: + _info("System update: skipped (--skip-update)") + + # 2. Remove pre-installed apps + print(f"\n{BOLD}── Remove Pre-installed Apps ──{RESET}", file=sys.stderr) + if not args.skip_remove: + results["remove"] = remove_packages(dry_run=args.dry_run) + else: + _info("Remove apps: skipped (--skip-remove)") + + # 3. Third-party repos + print(f"\n{BOLD}── Repositories ──{RESET}", file=sys.stderr) + if not args.skip_repos: + results["repos"] = setup_repos(dry_run=args.dry_run) + else: + _info("Repos: skipped (--skip-repos)") + + # 4. RPM packages + print(f"\n{BOLD}── RPM Packages ──{RESET}", file=sys.stderr) + if not args.skip_rpm: + results["rpm"] = install_rpm_packages(dry_run=args.dry_run) + if results["rpm"].get("failed"): + warnings.append( + f"Some RPM packages failed to install: {', '.join(results['rpm']['failed'])}" + ) + else: + _info("RPM packages: skipped (--skip-rpm)") + + # 5. Dev tool installers + print(f"\n{BOLD}── Dev Tool Installers ──{RESET}", file=sys.stderr) + if not args.skip_bun: + results["bun"] = setup_bun(dry_run=args.dry_run) + else: + _info("Bun: skipped (--skip-bun)") + + if not args.skip_zed: + results["zed"] = setup_zed(dry_run=args.dry_run) + else: + _info("Zed: skipped (--skip-zed)") + + if not args.skip_uv: + results["uv"] = setup_uv(dry_run=args.dry_run) + else: + _info("uv: skipped (--skip-uv)") + + # 6. Rust toolchain + print(f"\n{BOLD}── Rust Toolchain ──{RESET}", file=sys.stderr) + if not args.skip_rust: + results["rust"] = setup_rust(dry_run=args.dry_run) + else: + _info("Rust: skipped (--skip-rust)") + + # 7. Flatpak apps + print(f"\n{BOLD}── Flatpak Apps ──{RESET}", file=sys.stderr) + if not args.skip_flatpak: + results["flatpak"] = setup_flatpak(dry_run=args.dry_run) + if results["flatpak"].get("failed"): + warnings.append( + f"Some Flatpak apps failed to install: {', '.join(results['flatpak']['failed'])}" + ) + else: + _info("Flatpak: skipped (--skip-flatpak)") + + # 8. SSH + Git + print(f"\n{BOLD}── SSH + Git ──{RESET}", file=sys.stderr) + if not args.skip_ssh: + results["ssh"] = setup_ssh(dry_run=args.dry_run) + else: + _info("SSH + Git: skipped (--skip-ssh)") + + # 9. Firewall + print(f"\n{BOLD}── Firewall ──{RESET}", file=sys.stderr) + if not args.skip_firewall: + results["firewall"] = setup_firewall(dry_run=args.dry_run) + else: + _info("Firewall: skipped (--skip-firewall)") + + # 10. SELinux + print(f"\n{BOLD}── SELinux ──{RESET}", file=sys.stderr) + if not args.skip_selinux: + results["selinux"] = setup_selinux(dry_run=args.dry_run) + else: + _info("SELinux: skipped (--skip-selinux)") + + elapsed = time.monotonic() - start_time + print_summary(os_display, version_id, results, warnings, elapsed, dry_run=args.dry_run) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nInterrupted.", file=sys.stderr) + sys.exit(130) diff --git a/zabbix-deploy.py b/zabbix-deploy.py new file mode 100644 index 0000000..0630a35 --- /dev/null +++ b/zabbix-deploy.py @@ -0,0 +1,2010 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +# SPDX-License-Identifier: MIT + +"""Idempotent Zabbix 7.4 deployment with PostgreSQL 18, nginx, PHP-FPM. + +Deploys a full Zabbix monitoring stack with TLS-secured frontend on Fedora +or openEuler. Every operation checks current state before acting; running +the script repeatedly produces the same result with no errors and no +duplicate work. + +Architecture: + nginx :443 (HTTPS, self-signed, IPv6 + IPv4) → PHP-FPM (Zabbix frontend) + → PostgreSQL 18 (Zabbix DB) + Zabbix server + agent on localhost + +Password handling: + --db-password / --zabbix-password work but leak into shell history and + `ps` output — prefer the ZABBIX_DB_PASSWORD / ZABBIX_ADMIN_PASSWORD + environment variables. With no password supplied, one is generated; on + re-runs the existing DBPassword from zabbix_server.conf is reused. + +Usage: + ZABBIX_DB_PASSWORD=SECRET zabbix-deploy.py + zabbix-deploy.py --db-password SECRET --zabbix-password SECRET + zabbix-deploy.py --dry-run + zabbix-deploy.py --uninstall + zabbix-deploy.py --version +""" + +from __future__ import annotations + +import argparse +import os +import re +import secrets +import subprocess +import sys +import time +from typing import Any + +BOLD = "\033[1m" +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +DIM = "\033[2m" +RESET = "\033[0m" +VERSION = "1.0.0" + +SUPPORTED_OS: dict[str, str] = { + "fedora": "Fedora", + "openeuler": "openEuler", +} + +# PostgreSQL paths for version 18. +PG_BIN = "/usr/pgsql-18/bin" +PG_DATA = "/var/lib/pgsql/18/data" +PG_HBA = "/var/lib/pgsql/18/data/pg_hba.conf" +PG_SERVICE = "postgresql-18" +PG_SETUP = "/usr/pgsql-18/bin/postgresql-18-setup" + +# Zabbix paths. +ZABBIX_SERVER_CONF = "/etc/zabbix/zabbix_server.conf" +ZABBIX_SCHEMA = "/usr/share/zabbix/sql-scripts/postgresql/server.sql.gz" +ZABBIX_TIMESCALE = "/usr/share/zabbix/sql-scripts/postgresql/timescaledb.sql" +ZABBIX_NGINX_CONF = "/etc/nginx/conf.d/zabbix.conf" +ZABBIX_PHP_CONF = "/etc/php-fpm.d/zabbix.conf" +ZABBIX_CERT_DIR = "/etc/ssl/zabbix" +ZABBIX_CERT_KEY = "/etc/ssl/zabbix/zabbix.key" +ZABBIX_CERT_CRT = "/etc/ssl/zabbix/zabbix.crt" +PHP_FPM_SOCK = "/run/php-fpm/zabbix.sock" + + +# --------------------------------------------------------------------------- +# Progress helpers — write to stderr so stdout stays clean +# --------------------------------------------------------------------------- + + +def _status(msg: str) -> None: + """Print a progress message to stderr — stays on the same line until ``_status_done``.""" + print(f" {msg}...", end="", flush=True, file=sys.stderr) + + +def _status_done(msg: str = "done") -> None: + """Complete the current progress line on stderr.""" + print(f" {msg}", file=sys.stderr) + + +def _info(msg: str) -> None: + """Print an informational line to stderr.""" + print(f" {DIM}{msg}{RESET}", file=sys.stderr) + + +def _warn(msg: str) -> None: + """Print a warning to stderr.""" + print(f" {YELLOW}⚠ {msg}{RESET}", file=sys.stderr) + + +def _ok(msg: str) -> None: + """Print a success message to stderr.""" + print(f" {GREEN}✓ {msg}{RESET}", file=sys.stderr) + + +def _fail(msg: str) -> None: + """Print a failure message to stderr.""" + print(f" {RED}✗ {msg}{RESET}", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Low-level command runner +# --------------------------------------------------------------------------- + + +def run( + cmd: list[str], + timeout: int = 300, + check: bool = False, + env: dict[str, str] | None = None, + input: str | None = None, +) -> subprocess.CompletedProcess[str]: + """Execute a command and return the CompletedProcess. + + Sets LANG=C for reliable English-language output parsing. + Raises subprocess.CalledProcessError when *check* is True and the + command exits non-zero. *input* is fed to the command's stdin — use it + for anything secret so it never appears in the process command line. + """ + merged_env = {**os.environ, "LANG": "C"} + if env: + merged_env.update(env) + return subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, env=merged_env, input=input + ) + + +# --------------------------------------------------------------------------- +# OS detection +# --------------------------------------------------------------------------- + + +def parse_os_release() -> dict[str, str]: + """Parse /etc/os-release into a dict of KEY=VALUE pairs.""" + result: dict[str, str] = {} + try: + with open("/etc/os-release", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + value = value.strip().strip('"').strip("'") + result[key] = value + except OSError: + pass + return result + + +def detect_os() -> tuple[str, str, str]: + """Detect the operating system. + + Returns a tuple of (os_id, display_name, version_id). + Exits with an error message when the OS is not supported. + """ + release = parse_os_release() + os_id = release.get("ID", "").lower() + version_id = release.get("VERSION_ID", "unknown") + + if os_id not in SUPPORTED_OS: + print( + f"{RED}{BOLD}Error:{RESET} Unsupported operating system: " + f"'{release.get('ID', 'unknown')}' (detected from /etc/os-release).", + file=sys.stderr, + ) + print( + f" Supported systems: {', '.join(SUPPORTED_OS.values())}", + file=sys.stderr, + ) + sys.exit(1) + + return os_id, SUPPORTED_OS[os_id], version_id + + +# --------------------------------------------------------------------------- +# Root check +# --------------------------------------------------------------------------- + + +def check_root() -> None: + """Exit with a clear message if not running as root (UID 0).""" + if os.geteuid() != 0: + print( + f"{RED}{BOLD}Error:{RESET} This script must be run as root (UID 0).", + file=sys.stderr, + ) + print( + f" Current UID: {os.geteuid()}. Try: sudo python3 zabbix-deploy.py ...", + file=sys.stderr, + ) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Idempotency helpers +# --------------------------------------------------------------------------- + + +def _file_exists(path: str) -> bool: + """Return True if the path exists as a file.""" + return os.path.isfile(path) + + +def _dir_exists(path: str) -> bool: + """Return True if the path exists as a directory.""" + return os.path.isdir(path) + + +def _rpm_installed(pkg: str) -> bool: + """Return True if the RPM package is already installed.""" + result = run(["rpm", "-q", pkg]) + return result.returncode == 0 + + +def _systemctl_is_active(service: str) -> bool: + """Return True if the systemd service is active.""" + result = run(["systemctl", "is-active", "--quiet", service]) + return result.returncode == 0 + + +def _systemctl_is_enabled(service: str) -> bool: + """Return True if the systemd service is enabled.""" + result = run(["systemctl", "is-enabled", "--quiet", service]) + return result.returncode == 0 + + +def _pg_user_exists(db_user: str) -> bool: + """Return True if a PostgreSQL role already exists.""" + result = run( + [ + "sudo", + "-u", + "postgres", + f"{PG_BIN}/psql", + "-tAc", + f"SELECT 1 FROM pg_roles WHERE rolname='{db_user}'", + ] + ) + return result.returncode == 0 and "1" in result.stdout + + +def _pg_database_exists(db_name: str) -> bool: + """Return True if a PostgreSQL database already exists.""" + result = run( + [ + "sudo", + "-u", + "postgres", + f"{PG_BIN}/psql", + "-tAc", + f"SELECT 1 FROM pg_database WHERE datname='{db_name}'", + ] + ) + return result.returncode == 0 and "1" in result.stdout + + +def _pg_tables_exist() -> bool: + """Return True if the Zabbix schema is present (sentinel table dbversion).""" + result = run( + [ + "sudo", + "-u", + "postgres", + f"{PG_BIN}/psql", + "zabbix", + "-tAc", + "SELECT 1 FROM information_schema.tables " + "WHERE table_schema='public' AND table_name='dbversion'", + ] + ) + return result.returncode == 0 and "1" in result.stdout + + +def _pg_extension_exists(extension: str) -> bool: + """Return True if a PostgreSQL extension is enabled in the zabbix DB.""" + result = run( + [ + "sudo", + "-u", + "postgres", + f"{PG_BIN}/psql", + "zabbix", + "-tAc", + f"SELECT 1 FROM pg_extension WHERE extname='{extension}'", + ] + ) + return result.returncode == 0 and "1" in result.stdout + + +def _psql_stdin(sql: str, database: str | None = None) -> subprocess.CompletedProcess[str]: + """Run SQL as the postgres OS user (peer auth) with SQL on stdin. + + Passing SQL via stdin keeps secrets out of the process command line + (visible in `ps` and /proc) and out of the PostgreSQL log on error. + """ + cmd = ["sudo", "-u", "postgres", f"{PG_BIN}/psql", "-v", "ON_ERROR_STOP=1"] + if database: + cmd.append(database) + return run(cmd, input=sql, timeout=60) + + +def _read_existing_db_password() -> str | None: + """Return the current DBPassword from zabbix_server.conf, or None.""" + try: + with open(ZABBIX_SERVER_CONF, encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if stripped.startswith("DBPassword="): + value = stripped.split("=", 1)[1].strip() + return value or None + except OSError: + pass + return None + + +def _firewalld_active() -> bool: + """Return True if firewalld is running.""" + return _systemctl_is_active("firewalld") + + +def _firewall_rule_exists(rule: str, permanent: bool = True) -> bool: + """Return True if a firewall rule already exists.""" + args = ["firewall-cmd"] + if permanent: + args.append("--permanent") + if rule.startswith("--add-port"): + parts = rule.split("=") + if len(parts) == 2: + args.extend(["--query-port", parts[1]]) + elif rule.startswith("--add-service"): + parts = rule.split("=") + if len(parts) == 2: + args.extend(["--query-service", parts[1]]) + else: + return False + result = run(args, timeout=10) + return result.returncode == 0 + + +def _selinux_get_bool(boolean_name: str) -> bool | None: + """Return current state of an SELinux boolean, or None if unknown.""" + result = run(["getsebool", boolean_name], timeout=10) + if result.returncode != 0: + return None + # Output format: "boolean_name --> on" or "boolean_name --> off" + match = re.search(rf"{boolean_name}\s*-->\s*(on|off)", result.stdout) + if match: + return match.group(1) == "on" + return None + + +def _map_to_rhel_version(os_id: str, version_id: str) -> tuple[str, str]: + """Map an OS identity and version to the closest RHEL version pair. + + Returns a tuple of (el_major, rhel_version) for the Zabbix repo URL. + + Zabbix publishes RHEL repos only — Fedora and openEuler use the closest + RHEL release (PGDG PostgreSQL has native Fedora repos, handled in + setup_postgresql): + Fedora (any) -> el9 + openEuler 24+ -> el9 + openEuler 22 -> el8 + """ + if os_id == "fedora": + return ("9", "9") + + # openEuler + try: + major_ver = int(version_id.split(".")[0]) + except (ValueError, IndexError): + major_ver = 0 + + if major_ver >= 24: + return ("9", "9") + else: + return ("8", "8") + + +def _get_hostname() -> str: + """Return the system hostname or 'localhost'.""" + result = run(["hostname", "-f"], timeout=5) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + result = run(["hostname"], timeout=5) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + return "localhost" + + +# --------------------------------------------------------------------------- +# 1. Pre-flight checks +# --------------------------------------------------------------------------- + + +def preflight_checks(dry_run: bool = False) -> dict[str, Any]: + """Run pre-flight checks: OS, root, memory. + + Returns a dict with check results. + """ + results: dict[str, Any] = {} + + # Root + _status("Checking root privileges") + check_root() + _status_done("root") + + # OS + _status("Detecting operating system") + os_id, os_display, version_id = detect_os() + _status_done(f"{os_display} {version_id}") + results["os_id"] = os_id + results["os_display"] = os_display + results["version_id"] = version_id + + # Memory + _status("Checking system memory") + try: + with open("/proc/meminfo", encoding="utf-8") as fh: + for line in fh: + if line.startswith("MemTotal:"): + mem_kb = int(line.split(":")[1].strip().split()[0]) + mem_gb = mem_kb / (1024 * 1024) + results["memory_gb"] = round(mem_gb, 1) + if mem_gb < 2.0: + _status_done(f"{mem_gb:.1f} GB — LOW") + _warn( + "Less than 2 GB RAM detected. Zabbix with " + "PostgreSQL may perform poorly." + ) + else: + _status_done(f"{mem_gb:.1f} GB") + break + except OSError: + _status_done("unknown") + results["memory_gb"] = None + + return results + + +# --------------------------------------------------------------------------- +# 2. PostgreSQL 18 +# --------------------------------------------------------------------------- + + +def setup_postgresql( + os_id: str, + version_id: str, + db_password: str, + db_password_sync: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: + """Install and configure PostgreSQL 18. Idempotent.""" + results: dict[str, Any] = {} + + # Map to EL version for repo URLs (used for openEuler; Fedora has native + # PGDG reporpms). + el_major, _ = _map_to_rhel_version(os_id, version_id) + + # 2a. Add PostgreSQL official repo. PGDG publishes native Fedora reporpms + # (F-) — prefer them over the EL repos on Fedora. + _status("Adding PostgreSQL repository") + if os_id == "fedora": + pg_repo_pkg = "pgdg-fedora-repo" + pg_repo_url = ( + "https://download.postgresql.org/pub/repos/yum/reporpms/" + f"F-{version_id}-x86_64/pgdg-fedora-repo-latest.noarch.rpm" + ) + else: + pg_repo_pkg = "pgdg-redhat-repo" + pg_repo_url = ( + "https://download.postgresql.org/pub/repos/yum/reporpms/" + f"EL-{el_major}-x86_64/pgdg-redhat-repo-latest.noarch.rpm" + ) + if _rpm_installed(pg_repo_pkg): + _status_done("already present") + elif dry_run: + _status_done("dry run") + _info(f" Would install: {pg_repo_url}") + else: + install_result = run(["dnf", "install", "-y", pg_repo_url], timeout=120) + if install_result.returncode != 0: + _status_done("failed") + _fail(f"dnf: {install_result.stderr.strip()}") + results["pg_repo"] = False + return results + _status_done("installed") + results["pg_repo"] = True + + # 2b. Disable built-in PostgreSQL module (Fedora only). + if os_id == "fedora": + _status("Disabling built-in PostgreSQL module") + mod_check = run(["dnf", "module", "list", "--enabled", "postgresql"], timeout=30) + if "postgresql" in mod_check.stdout: + if dry_run: + _status_done("dry run") + _info(" Would run: dnf module disable postgresql -y") + else: + disable_result = run(["dnf", "module", "disable", "postgresql", "-y"], timeout=120) + if disable_result.returncode != 0: + _status_done("failed (non-fatal)") + _warn(f"Module disable: {disable_result.stderr.strip()}") + else: + _status_done("disabled") + else: + _status_done("not present") + else: + _status("PostgreSQL module disable") + _status_done("skipped (openEuler)") + + # 2c. Install PostgreSQL 18 packages. + pg_packages = ["postgresql18-server", "postgresql18"] + for pkg in pg_packages: + _status(f"Installing {pkg}") + if _rpm_installed(pkg): + _status_done("already present") + elif dry_run: + _status_done("dry run") + else: + inst = run(["dnf", "install", "-y", pkg], timeout=300) + if inst.returncode != 0: + _status_done("failed") + _fail(f"dnf: {inst.stderr.strip()}") + results[f"pkg_{pkg}"] = False + return results + _status_done("installed") + results[f"pkg_{pkg}"] = True + + # 2d. Init DB (only if data directory empty). + _status("Initialising PostgreSQL database") + if _dir_exists(PG_DATA) and os.listdir(PG_DATA): + _status_done("already initialised") + elif dry_run: + _status_done("dry run") + _info(f" Would run: {PG_SETUP} initdb (UTF8 cluster)") + else: + # Guarantee a UTF8 cluster (Zabbix requires UTF8) regardless of the + # locale this script runs under. + init_result = run( + [PG_SETUP, "initdb"], + env={"PGSETUP_INITDB_OPTIONS": "--encoding=UTF8 --locale=C.UTF-8"}, + timeout=120, + ) + if init_result.returncode != 0: + _status_done("failed") + _fail(f"initdb: {init_result.stderr.strip()}") + results["pg_initdb"] = False + return results + _status_done("initialised") + results["pg_initdb"] = True + + # 2e. Add scoped scram-sha-256 lines for the zabbix role's TCP connections. + # The stock `local all all peer` line stays untouched — every psql call + # in this script relies on peer auth as the postgres/zabbix OS users. The + # scoped host lines guarantee password auth works for zabbix-server and + # the frontend regardless of the stock host catch-all method. + _status("Configuring pg_hba.conf (zabbix scram-sha-256)") + scoped_lines = [ + "host zabbix zabbix ::1/128 scram-sha-256", + "host zabbix zabbix 127.0.0.1/32 scram-sha-256", + ] + if not _file_exists(PG_HBA): + _status_done("not found") + else: + try: + with open(PG_HBA, encoding="utf-8") as fh: + hba_content = fh.read() + missing = [line for line in scoped_lines if line not in hba_content] + if not missing: + _status_done("already configured") + elif dry_run: + _status_done("dry run") + _info(" Would add scoped zabbix scram-sha-256 lines to pg_hba.conf") + else: + lines = hba_content.split("\n") + # pg_hba is first-match-wins: insert before the first active + # host line so the scoped rules take precedence over catch-alls. + insert_at = next( + ( + i + for i, line in enumerate(lines) + if line.strip().startswith("host") and not line.strip().startswith("#") + ), + len(lines), + ) + lines[insert_at:insert_at] = [ + "# zabbix-deploy: scoped auth for the zabbix role", + *missing, + ] + with open(PG_HBA, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines)) + _status_done("updated") + # Reload so the change takes effect on an already-running server. + if _systemctl_is_active(PG_SERVICE): + run(["systemctl", "reload", PG_SERVICE], timeout=30) + except OSError as exc: + _status_done("failed") + _fail(f"pg_hba.conf: {exc}") + + # 2f. Enable and start PostgreSQL. + _status("Enabling and starting postgresql-18") + if dry_run: + _status_done("dry run") + _info(" Would systemctl enable --now postgresql-18") + else: + if not _systemctl_is_enabled(PG_SERVICE): + run(["systemctl", "enable", PG_SERVICE], timeout=30) + if not _systemctl_is_active(PG_SERVICE): + run(["systemctl", "start", PG_SERVICE], timeout=60) + if _systemctl_is_active(PG_SERVICE): + _status_done("running") + else: + _status_done("failed to start") + results["pg_service"] = False + return results + results["pg_service"] = True + + # 2g. Create zabbix database user (password via stdin, never argv). + escaped_pw = db_password.replace("'", "''") + _status("Creating zabbix database user") + if dry_run: + _status_done("dry run") + _info(" Would create user 'zabbix' with the provided password") + elif _pg_user_exists("zabbix"): + if db_password_sync: + # Explicit password supplied — keep the role in sync with the + # password written to zabbix_server.conf. + alter = _psql_stdin(f"ALTER USER zabbix WITH PASSWORD '{escaped_pw}'") + if alter.returncode != 0: + _status_done("failed") + _fail(f"psql: {alter.stderr.strip()}") + results["pg_user"] = False + return results + _status_done("already exists (password synchronised)") + else: + _status_done("already exists") + else: + create_user = _psql_stdin(f"CREATE USER zabbix WITH PASSWORD '{escaped_pw}'") + if create_user.returncode != 0: + _status_done("failed") + _fail(f"psql: {create_user.stderr.strip()}") + results["pg_user"] = False + return results + _status_done("created") + results["pg_user"] = True + + # 2h. Create zabbix database. + _status("Creating zabbix database") + if dry_run: + _status_done("dry run") + _info(" Would create database 'zabbix' owned by 'zabbix'") + elif _pg_database_exists("zabbix"): + _status_done("already exists") + else: + create_db = _psql_stdin("CREATE DATABASE zabbix OWNER zabbix") + if create_db.returncode != 0: + _status_done("failed") + _fail(f"psql: {create_db.stderr.strip()}") + results["pg_database"] = False + return results + _status_done("created") + results["pg_database"] = True + + return results + + +# --------------------------------------------------------------------------- +# 3. Zabbix Repository +# --------------------------------------------------------------------------- + + +def setup_zabbix_repo( + os_id: str, + version_id: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Add the Zabbix 7.4 official repository. Idempotent.""" + results: dict[str, Any] = {} + + # Zabbix has no Fedora/openEuler-specific repo; the closest RHEL release + # is used (assumption: el8/el9 packages work on the mapped targets). + el_major, _ = _map_to_rhel_version(os_id, version_id) + zabbix_repo_rpm = "zabbix-release" + zabbix_repo_url = ( + f"https://repo.zabbix.com/zabbix/7.4/release/rhel/" + f"{el_major}/noarch/zabbix-release-latest.el{el_major}.noarch.rpm" + ) + + # 3a. Exclude Zabbix from EPEL to avoid conflicts. + epel_repo = "/etc/yum.repos.d/epel.repo" + _status("Checking EPEL for zabbix exclusion") + if _file_exists(epel_repo): + try: + with open(epel_repo, encoding="utf-8") as fh: + epel_content = fh.read() + if "exclude=zabbix*" in epel_content: + _status_done("already excluded") + elif dry_run: + _status_done("dry run") + _info(" Would add exclude=zabbix* to EPEL repo") + else: + # Add exclude line to the [epel] section. + new_lines = [] + for line in epel_content.split("\n"): + new_lines.append(line) + if line.strip().startswith("[epel]"): + new_lines.append("exclude=zabbix*") + with open(epel_repo, "w", encoding="utf-8") as fh: + fh.write("\n".join(new_lines)) + _status_done("exclusion added") + except OSError as exc: + _status_done("failed") + _warn(f"EPEL exclusion: {exc}") + else: + _status_done("EPEL not present") + + # 3b. Install Zabbix repo RPM. + _status("Installing Zabbix repository") + if _rpm_installed(zabbix_repo_rpm): + _status_done("already present") + elif dry_run: + _status_done("dry run") + _info(f" Would run: rpm -Uvh {zabbix_repo_url}") + else: + inst = run(["rpm", "-Uvh", zabbix_repo_url], timeout=60) + if inst.returncode != 0: + _status_done("failed") + _fail(f"rpm: {inst.stderr.strip()}") + results["zabbix_repo"] = False + return results + _status_done("installed") + results["zabbix_repo"] = True + + return results + + +# --------------------------------------------------------------------------- +# 4. Zabbix Packages +# --------------------------------------------------------------------------- + + +def setup_zabbix_packages(dry_run: bool = False) -> dict[str, Any]: + """Install Zabbix server, frontend, and agent packages. Idempotent.""" + results: dict[str, Any] = {} + # php-fpm/php-cli/php-pgsql/nginx are listed explicitly — they are not + # hard dependencies of zabbix-frontend-php (php-cli hashes the Admin + # password, php-pgsql provides the frontend's PostgreSQL driver). + packages = [ + "zabbix-server-pgsql", + "zabbix-frontend-php", + "zabbix-nginx-conf", + "zabbix-sql-scripts", + "zabbix-selinux-policy", + "zabbix-agent", + "nginx", + "php-fpm", + "php-cli", + "php-pgsql", + ] + + for pkg in packages: + _status(f"Installing {pkg}") + if _rpm_installed(pkg): + _status_done("already present") + elif dry_run: + _status_done("dry run") + else: + inst = run(["dnf", "install", "-y", pkg], timeout=300) + if inst.returncode != 0: + _status_done("failed") + _fail(f"dnf: {inst.stderr.strip()}") + results[f"pkg_{pkg}"] = False + return results + _status_done("installed") + results[f"pkg_{pkg}"] = True + + return results + + +# --------------------------------------------------------------------------- +# 5. Database Schema +# --------------------------------------------------------------------------- + + +def setup_database_schema(dry_run: bool = False) -> dict[str, Any]: + """Import Zabbix schema into PostgreSQL. Idempotent.""" + results: dict[str, Any] = {} + + # 5a. Import base schema. Runs as the zabbix OS user via peer auth, so no + # password is needed and the imported tables are owned by the zabbix + # role — the official Zabbix documentation imports as the zabbix DB user + # for exactly this ownership reason. + _status("Importing Zabbix schema") + if not _file_exists(ZABBIX_SCHEMA): + _status_done("schema file not found") + _warn(f"Missing: {ZABBIX_SCHEMA}") + results["schema"] = False + return results + + if _pg_tables_exist(): + _status_done("already imported") + results["schema"] = True + return results + + if dry_run: + _status_done("dry run") + _info(f" Would run: zcat {ZABBIX_SCHEMA} | sudo -u zabbix psql -d zabbix") + results["schema"] = True + return results + + import_cmd = [ + "bash", + "-c", + f"zcat {ZABBIX_SCHEMA} | sudo -u zabbix {PG_BIN}/psql -v ON_ERROR_STOP=1 -d zabbix", + ] + import_result = run(import_cmd, timeout=300) + if import_result.returncode != 0: + _status_done("failed") + _fail(f"schema import: {import_result.stderr.strip()}") + results["schema"] = False + return results + _status_done("imported") + results["schema"] = True + results["schema_fresh"] = True + + # 5b. Import TimescaleDB schema if available and not already applied + # (idempotency marker: the extension row in pg_extension). + _status("Checking TimescaleDB availability") + ts_check = run( + [ + "sudo", + "-u", + "postgres", + f"{PG_BIN}/psql", + "zabbix", + "-tAc", + "SELECT 1 FROM pg_available_extensions WHERE name='timescaledb'", + ], + timeout=10, + ) + if ts_check.returncode != 0 or "1" not in ts_check.stdout: + _status_done("not available — skipped") + elif not _file_exists(ZABBIX_TIMESCALE): + _status_done("script not found — skipped") + elif _pg_extension_exists("timescaledb"): + _status_done("already applied") + results["timescaledb"] = True + elif dry_run: + _status_done("dry run") + _info(f" Would import: {ZABBIX_TIMESCALE}") + else: + # CREATE EXTENSION needs the superuser; the script itself then runs + # as the zabbix user (its own CREATE EXTENSION IF NOT EXISTS becomes + # a no-op needing no privileges). + ext = _psql_stdin( + "CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE", + database="zabbix", + ) + if ext.returncode != 0: + _status_done("extension failed (non-fatal)") + _warn(f"TimescaleDB extension: {ext.stderr.strip()}") + else: + ts_result = run( + [ + "bash", + "-c", + f"cat {ZABBIX_TIMESCALE} | sudo -u zabbix {PG_BIN}/psql -d zabbix", + ], + timeout=120, + ) + if ts_result.returncode != 0: + _status_done("import failed (non-fatal)") + _warn(f"TimescaleDB: {ts_result.stderr.strip()}") + else: + _status_done("imported") + results["timescaledb"] = True + + return results + + +# --------------------------------------------------------------------------- +# 5b. Zabbix Admin Password +# --------------------------------------------------------------------------- + + +def set_admin_password( + zabbix_password: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Set the Zabbix Admin frontend password. + + The hash is produced by PHP's password_hash(PASSWORD_BCRYPT) — the same + algorithm the Zabbix frontend uses for users.passwd since Zabbix 5.0 + (ZBXNEXT-1898). Both the password and the SQL travel via stdin, never + via the process command line. + """ + results: dict[str, Any] = {} + + _status("Setting Zabbix Admin password") + if dry_run: + _status_done("dry run") + _info(" Would UPDATE users SET passwd= WHERE username='Admin'") + return results + + if not _pg_tables_exist(): + _status_done("schema not present — skipped") + results["admin_password"] = False + return results + + php = run( + [ + "php", + "-r", + "echo password_hash(trim(stream_get_contents(STDIN)), PASSWORD_BCRYPT);", + ], + input=zabbix_password, + timeout=30, + ) + pw_hash = php.stdout.strip() + if php.returncode != 0 or not pw_hash.startswith("$2"): + _status_done("failed") + _fail(f"php password_hash: {php.stderr.strip()}") + results["admin_password"] = False + return results + + # bcrypt hashes contain only [A-Za-z0-9./$] — safe in single-quoted SQL. + update = _psql_stdin( + f"UPDATE users SET passwd='{pw_hash}', attempt_failed=0 WHERE username='Admin'", + database="zabbix", + ) + if update.returncode != 0: + _status_done("failed") + _fail(f"psql: {update.stderr.strip()}") + results["admin_password"] = False + return results + + _status_done("set") + results["admin_password"] = True + return results + + +# --------------------------------------------------------------------------- +# 6. Zabbix Server Config +# --------------------------------------------------------------------------- + + +def setup_zabbix_server( + db_password: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Configure and start Zabbix server. Idempotent.""" + results: dict[str, Any] = {} + + # 6a. Configure zabbix_server.conf. + _status("Configuring zabbix_server.conf") + if not _file_exists(ZABBIX_SERVER_CONF): + _status_done("not found") + _warn(f"Missing: {ZABBIX_SERVER_CONF}") + results["server_config"] = False + return results + + config_changed = False + if dry_run: + _status_done("dry run") + _info(" Would set DBPassword, DBHost=localhost, ListenPort=10051") + else: + try: + with open(ZABBIX_SERVER_CONF, encoding="utf-8") as fh: + config = fh.read() + + changed = False + new_lines = [] + + for line in config.split("\n"): + stripped = line.strip() + + if stripped.startswith("DBPassword=") and not stripped.startswith("#"): + current_val = stripped.split("=", 1)[1] if "=" in stripped else "" + if current_val != db_password: + line = f"DBPassword={db_password}" + changed = True + elif stripped.startswith("# DBPassword="): + line = f"DBPassword={db_password}" + changed = True + elif stripped.startswith("DBHost=") and not stripped.startswith("#"): + if "localhost" not in stripped: + line = "DBHost=localhost" + changed = True + elif stripped.startswith("# DBHost="): + line = "DBHost=localhost" + changed = True + elif stripped.startswith("ListenPort=") and not stripped.startswith("#"): + if "10051" not in stripped: + line = "ListenPort=10051" + changed = True + elif stripped.startswith("# ListenPort="): + line = "ListenPort=10051" + changed = True + + new_lines.append(line) + + if changed: + with open(ZABBIX_SERVER_CONF, "w", encoding="utf-8") as fh: + fh.write("\n".join(new_lines)) + _status_done("updated") + config_changed = True + else: + _status_done("already configured") + except OSError as exc: + _status_done("failed") + _fail(f"config: {exc}") + results["server_config"] = False + return results + results["server_config"] = True + + # 6b. Enable and start zabbix-server. + _status("Enabling and starting zabbix-server") + if dry_run: + _status_done("dry run") + _info(" Would systemctl enable --now zabbix-server") + else: + if not _systemctl_is_enabled("zabbix-server"): + run(["systemctl", "enable", "zabbix-server"], timeout=30) + if not _systemctl_is_active("zabbix-server"): + run(["systemctl", "start", "zabbix-server"], timeout=60) + elif config_changed: + # Config changed — restart so the new DB credentials take effect. + run(["systemctl", "restart", "zabbix-server"], timeout=60) + if _systemctl_is_active("zabbix-server"): + _status_done("running") + else: + _status_done("failed to start") + _warn("Check: journalctl -u zabbix-server") + results["server_service"] = _systemctl_is_active("zabbix-server") + + # 6c. Enable and start zabbix-agent. + _status("Enabling and starting zabbix-agent") + if dry_run: + _status_done("dry run") + _info(" Would systemctl enable --now zabbix-agent") + else: + if not _systemctl_is_enabled("zabbix-agent"): + run(["systemctl", "enable", "zabbix-agent"], timeout=30) + if not _systemctl_is_active("zabbix-agent"): + run(["systemctl", "start", "zabbix-agent"], timeout=60) + if _systemctl_is_active("zabbix-agent"): + _status_done("running") + else: + _status_done("failed to start") + _warn("Check: journalctl -u zabbix-agent") + results["agent_service"] = _systemctl_is_active("zabbix-agent") + + return results + + +# --------------------------------------------------------------------------- +# 8. nginx + PHP-FPM (TLS) +# --------------------------------------------------------------------------- + + +def _nginx_server_block() -> str: + """Return the managed TLS nginx server block for the Zabbix frontend. + + Mirrors the stock zabbix-nginx-conf locations but listens on 443 with + SSL (IPv6 first, IPv4 fallback) instead of the stock plain-HTTP :8080. + """ + template = r"""# Managed by zabbix-deploy.py — do not edit; changes will be overwritten. +server { + listen [::]:443 ssl; + listen 443 ssl; + server_name _; + + ssl_certificate @CERT@; + ssl_certificate_key @KEY@; + + root /usr/share/zabbix; + index index.php; + + location = /favicon.ico { + log_not_found off; + } + + location / { + try_files $uri $uri/ =404; + } + + location /assets { + access_log off; + expires 10d; + } + + location ~ /\.ht { + deny all; + } + + location ~ /(api\/|conf[^\.]|include|locale) { + deny all; + return 404; + } + + location /vendor { + deny all; + return 404; + } + + location ~* [^/]\.php(/|$) { + fastcgi_pass unix:@SOCK@; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_index index.php; + + fastcgi_param DOCUMENT_ROOT /usr/share/zabbix; + fastcgi_param SCRIPT_FILENAME /usr/share/zabbix$fastcgi_script_name; + fastcgi_param PATH_TRANSLATED /usr/share/zabbix$fastcgi_script_name; + + include fastcgi_params; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + + fastcgi_intercept_errors on; + fastcgi_ignore_client_abort off; + fastcgi_connect_timeout 60; + fastcgi_send_timeout 180; + fastcgi_read_timeout 180; + fastcgi_buffer_size 128k; + fastcgi_buffers 4 256k; + fastcgi_busy_buffers_size 256k; + fastcgi_temp_file_write_size 256k; + } +} +""" + return ( + template.replace("@CERT@", ZABBIX_CERT_CRT) + .replace("@KEY@", ZABBIX_CERT_KEY) + .replace("@SOCK@", PHP_FPM_SOCK) + ) + + +def setup_nginx_php( + timezone: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Configure nginx (TLS) and PHP-FPM for Zabbix frontend. Idempotent.""" + results: dict[str, Any] = {} + + # 8a. Configure PHP-FPM timezone. + _status("Configuring PHP-FPM for Zabbix") + php_changed = False + if not _file_exists(ZABBIX_PHP_CONF): + _status_done("not found") + _warn(f"Missing: {ZABBIX_PHP_CONF}") + elif dry_run: + _status_done("dry run") + _info(f" Would set date.timezone={timezone}") + else: + try: + with open(ZABBIX_PHP_CONF, encoding="utf-8") as fh: + php_conf = fh.read() + + new_lines = [] + for line in php_conf.split("\n"): + if "php_value[date.timezone]" in line and not line.strip().startswith(";"): + if timezone not in line: + line = f"php_value[date.timezone] = {timezone}" + php_changed = True + elif line.strip().startswith(";php_value[date.timezone]"): + line = f"php_value[date.timezone] = {timezone}" + php_changed = True + new_lines.append(line) + + if php_changed: + with open(ZABBIX_PHP_CONF, "w", encoding="utf-8") as fh: + fh.write("\n".join(new_lines)) + _status_done("updated") + else: + _status_done("already configured") + except OSError as exc: + _status_done("failed") + _fail(f"PHP-FPM config: {exc}") + + # 8b. Write the managed TLS server block (replacing the stock plain-HTTP + # :8080 block) and validate it with nginx -t before it may take effect. + _status("Configuring nginx for Zabbix (TLS :443)") + nginx_changed = False + nginx_config_ok = True + if dry_run: + _status_done("dry run") + _info(f" Would write TLS server block to {ZABBIX_NGINX_CONF}") + else: + try: + desired = _nginx_server_block() + current = "" + if _file_exists(ZABBIX_NGINX_CONF): + with open(ZABBIX_NGINX_CONF, encoding="utf-8") as fh: + current = fh.read() + if current == desired: + _status_done("already configured") + else: + with open(ZABBIX_NGINX_CONF, "w", encoding="utf-8") as fh: + fh.write(desired) + test = run(["nginx", "-t"], timeout=30) + if test.returncode != 0: + _status_done("config test failed") + _fail(f"nginx -t: {test.stderr.strip()}") + # Roll back so nginx is not left with a broken config. + if current: + with open(ZABBIX_NGINX_CONF, "w", encoding="utf-8") as fh: + fh.write(current) + nginx_config_ok = False + else: + _status_done("written (TLS :443)") + nginx_changed = True + except OSError as exc: + _status_done("failed") + _fail(f"nginx config: {exc}") + nginx_config_ok = False + results["nginx_config"] = nginx_config_ok + + # 8c. Enable and start php-fpm (reload when the pool config changed). + _status("Enabling and starting php-fpm") + if dry_run: + _status_done("dry run") + _info(" Would systemctl enable --now php-fpm") + else: + if not _systemctl_is_enabled("php-fpm"): + run(["systemctl", "enable", "php-fpm"], timeout=30) + if not _systemctl_is_active("php-fpm"): + run(["systemctl", "start", "php-fpm"], timeout=60) + elif php_changed: + run(["systemctl", "reload", "php-fpm"], timeout=30) + if _systemctl_is_active("php-fpm"): + _status_done("running") + else: + _status_done("failed to start") + _warn("Check: journalctl -u php-fpm") + + # 8d. Enable and start nginx (reload only after a successful nginx -t). + _status("Enabling and starting nginx") + if dry_run: + _status_done("dry run") + _info(" Would systemctl enable --now nginx") + elif not nginx_config_ok: + _status_done("skipped (config test failed)") + else: + if not _systemctl_is_enabled("nginx"): + run(["systemctl", "enable", "nginx"], timeout=30) + if not _systemctl_is_active("nginx"): + run(["systemctl", "start", "nginx"], timeout=60) + elif nginx_changed: + run(["systemctl", "reload", "nginx"], timeout=30) + if _systemctl_is_active("nginx"): + _status_done("running") + else: + _status_done("failed to start") + _warn("Check: journalctl -u nginx") + results["nginx"] = _systemctl_is_active("nginx") + results["php_fpm"] = _systemctl_is_active("php-fpm") + + return results + + +# --------------------------------------------------------------------------- +# 7. TLS Certificate +# --------------------------------------------------------------------------- + + +def setup_tls(dry_run: bool = False) -> dict[str, Any]: + """Create self-signed TLS certificate for nginx. Idempotent.""" + results: dict[str, Any] = {} + + _status("Setting up TLS certificate") + if _file_exists(ZABBIX_CERT_KEY) and _file_exists(ZABBIX_CERT_CRT): + _status_done("already present") + results["cert"] = "exists" + return results + + if dry_run: + _status_done("dry run") + _info(f" Would create self-signed cert in {ZABBIX_CERT_DIR}") + return results + + hostname = _get_hostname() + # Restrictive umask so the private key is never world-readable, not even + # in the window between openssl writing it and the chmod below. + old_umask = os.umask(0o077) + try: + if not _dir_exists(ZABBIX_CERT_DIR): + os.makedirs(ZABBIX_CERT_DIR, mode=0o700, exist_ok=True) + cert_result = run( + [ + "openssl", + "req", + "-x509", + "-nodes", + "-days", + "365", + "-newkey", + "rsa:2048", + "-keyout", + ZABBIX_CERT_KEY, + "-out", + ZABBIX_CERT_CRT, + "-subj", + f"/CN={hostname}", + "-addext", + f"subjectAltName=DNS:{hostname}", + ], + timeout=60, + ) + finally: + os.umask(old_umask) + if cert_result.returncode != 0: + _status_done("failed") + _fail(f"openssl: {cert_result.stderr.strip()}") + results["cert"] = False + return results + + # Set proper permissions. + os.chmod(ZABBIX_CERT_KEY, 0o600) + os.chmod(ZABBIX_CERT_CRT, 0o644) + _status_done("created") + results["cert"] = "created" + + return results + + +# --------------------------------------------------------------------------- +# 9. SELinux +# --------------------------------------------------------------------------- + + +def setup_selinux(dry_run: bool = False) -> dict[str, Any]: + """Configure SELinux booleans for Zabbix. Idempotent.""" + results: dict[str, Any] = {} + + # Check if SELinux is enforcing. + selinux_mode = "" + try: + with open("/sys/fs/selinux/enforce", encoding="utf-8") as fh: + selinux_mode = fh.read().strip() + except OSError: + pass + + if selinux_mode != "1": + _status("SELinux") + _status_done("not enforcing — skipped") + return results + + booleans = { + "httpd_can_network_connect_db": "Allow HTTPd to connect to PostgreSQL", + "zabbix_can_network": "Allow Zabbix to use network", + } + + for boolean, description in booleans.items(): + _status(f"SELinux: {description}") + current = _selinux_get_bool(boolean) + if current is None: + _status_done("boolean not found — skipped") + elif current is True: + _status_done("already on") + results[boolean] = True + elif dry_run: + _status_done("dry run") + _info(f" Would run: setsebool -P {boolean} on") + else: + set_result = run(["setsebool", "-P", boolean, "on"], timeout=10) + if set_result.returncode != 0: + _status_done("failed") + _warn(f"setsebool {boolean}: {set_result.stderr.strip()}") + results[boolean] = False + else: + _status_done("enabled") + results[boolean] = True + + return results + + +# --------------------------------------------------------------------------- +# 10. Firewall +# --------------------------------------------------------------------------- + + +def setup_firewall( + skip_firewall: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: + """Open firewall ports for Zabbix. Idempotent.""" + results: dict[str, Any] = {} + + if skip_firewall: + _status("Firewall") + _status_done("skipped by --skip-firewall") + return results + + if not _firewalld_active(): + _status("Firewall") + _status_done("firewalld not running — skipped") + results["firewall_active"] = False + return results + + results["firewall_active"] = True + + # Every component (server, agent, database) runs on localhost — only + # the HTTPS frontend needs to be reachable. The server trapper port + # 10051 deliberately stays closed. + rules = [ + ("--add-service=https", "service https (frontend :443)"), + ] + + for rule, label in rules: + _status(f"Firewall: {label}") + if _firewall_rule_exists(rule, permanent=True): + _status_done("already allowed") + elif dry_run: + _status_done("dry run") + _info(f" Would run: firewall-cmd {rule} --permanent") + else: + parts = rule.split("=") + if len(parts) != 2: + _status_done("invalid rule") + continue + fw_cmd = ["firewall-cmd"] + if rule.startswith("--add-port"): + fw_cmd.extend(["--add-port", parts[1]]) + elif rule.startswith("--add-service"): + fw_cmd.extend(["--add-service", parts[1]]) + fw_cmd.append("--permanent") + fw_result = run(fw_cmd, timeout=30) + if fw_result.returncode != 0: + _status_done("failed") + _warn(f"firewall-cmd: {fw_result.stderr.strip()}") + else: + _status_done("added") + results[label] = True + + # Reload firewall rules. + _status("Reloading firewall") + if dry_run: + _status_done("dry run") + else: + reload_result = run(["firewall-cmd", "--reload"], timeout=30) + if reload_result.returncode == 0: + _status_done("reloaded") + else: + _status_done("reload failed") + _warn(f"firewall-cmd reload: {reload_result.stderr.strip()}") + results["firewall_configured"] = True + + return results + + +# --------------------------------------------------------------------------- +# 12. Uninstall +# --------------------------------------------------------------------------- + + +def uninstall(dry_run: bool = False) -> dict[str, Any]: + """Tear down the Zabbix deployment. Idempotent. + + Package-level removal only. PostgreSQL itself, the zabbix database and + role, firewall rules, SELinux booleans and repository configuration are + deliberately left untouched — the exact revert commands are listed at + the end. + """ + results: dict[str, Any] = {} + + services = ["zabbix-server", "zabbix-agent", "php-fpm", "nginx", PG_SERVICE] + + # Stop and disable all services. + for svc in services: + _status(f"Stopping {svc}") + if _systemctl_is_active(svc) or _systemctl_is_enabled(svc): + if dry_run: + _status_done("dry run") + else: + run(["systemctl", "stop", svc], timeout=60) + run(["systemctl", "disable", svc], timeout=30) + _status_done("stopped and disabled") + else: + _status_done("not running") + + # Remove Zabbix packages. + zabbix_packages = [ + "zabbix-server-pgsql", + "zabbix-frontend-php", + "zabbix-nginx-conf", + "zabbix-sql-scripts", + "zabbix-selinux-policy", + "zabbix-agent", + ] + installed = [p for p in zabbix_packages if _rpm_installed(p)] + if installed: + _status(f"Removing Zabbix packages ({len(installed)})") + if dry_run: + _status_done("dry run") + else: + rm_result = run(["dnf", "remove", "-y"] + installed, timeout=300) + if rm_result.returncode != 0: + _status_done("failed") + _warn(f"dnf remove: {rm_result.stderr.strip()}") + else: + _status_done("removed") + results["packages_removed"] = rm_result.returncode == 0 + else: + _status("Removing Zabbix packages") + _status_done("nothing to remove") + + # Remove Zabbix repo. + _status("Removing Zabbix repository") + if _rpm_installed("zabbix-release"): + if dry_run: + _status_done("dry run") + else: + run(["rpm", "-e", "zabbix-release"], timeout=30) + _status_done("removed") + else: + _status_done("not present") + + # Remove TLS certificates (created by this script, not RPM-owned). + _status("Removing TLS certificates") + if _dir_exists(ZABBIX_CERT_DIR): + if dry_run: + _status_done("dry run") + else: + for fname in ("zabbix.key", "zabbix.crt"): + fpath = os.path.join(ZABBIX_CERT_DIR, fname) + if _file_exists(fpath): + os.remove(fpath) + try: + os.rmdir(ZABBIX_CERT_DIR) + except OSError: + pass + _status_done("removed") + else: + _status_done("not present") + + # The nginx/PHP-FPM Zabbix configs are RPM-owned — `dnf remove` handles + # them (locally modified files are preserved as .rpmsave; see below). + + # What deliberately persists, with the exact revert commands. + leftovers = [ + ( + "zabbix database and role", + 'sudo -u postgres psql -c "DROP DATABASE zabbix" -c "DROP USER zabbix"', + ), + ("PostgreSQL 18 data directory", "rm -rf /var/lib/pgsql/18/data"), + ( + "pg_hba.conf zabbix scram lines", + "edit /var/lib/pgsql/18/data/pg_hba.conf (remove the marked lines)", + ), + ( + "SELinux booleans", + "setsebool -P httpd_can_network_connect_db off && setsebool -P zabbix_can_network off", + ), + ( + "firewall https rule", + "firewall-cmd --permanent --remove-service=https && firewall-cmd --reload", + ), + ("PGDG repository", "dnf remove pgdg-redhat-repo pgdg-fedora-repo"), + ( + "EPEL zabbix exclusion", + "edit /etc/yum.repos.d/epel.repo (remove exclude=zabbix*)", + ), + ( + "modified config backups", + "rm -f /etc/nginx/conf.d/zabbix.conf.rpmsave /etc/php-fpm.d/zabbix.conf.rpmsave", + ), + ] + print( + f"\n {BOLD}Persists after uninstall (revert manually if desired):{RESET}", + file=sys.stderr, + ) + for label, command in leftovers: + print(f" • {label}: {DIM}{command}{RESET}", file=sys.stderr) + + return results + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def print_summary( + zabbix_password: str, + db_password: str, + os_display: str, + version_id: str, + results: dict[str, Any], + warnings: list[str], + elapsed: float, + dry_run: bool = False, + uninstall_mode: bool = False, +) -> None: + """Print a final summary of all sections to stderr.""" + mode = "Uninstall" if uninstall_mode else ("Dry Run" if dry_run else "Summary") + print(f"\n{BOLD}── {mode} ──{RESET}", file=sys.stderr) + + if uninstall_mode: + ur = results.get("uninstall", {}) + for key, label in [ + ("packages_removed", "Zabbix packages removed"), + ]: + if ur.get(key): + _ok(label) + elif dry_run: + _info("Would install and configure:") + for label in ( + "PostgreSQL 18 (UTF8 cluster, zabbix role + database)", + "Zabbix 7.4 repository and packages", + "Database schema import (as zabbix user, peer auth)", + "Zabbix Admin password (bcrypt via PHP password_hash)", + "Zabbix server + agent", + "TLS certificate and nginx HTTPS frontend on :443", + "PHP-FPM, SELinux booleans, firewall (https)", + ): + _info(f" • {label}") + else: + # Postgres + pg = results.get("postgresql", {}) + if pg.get("pg_service") and pg.get("pg_user") and pg.get("pg_database"): + _ok("PostgreSQL 18: running") + elif not pg: + pass # Not attempted. + else: + _fail("PostgreSQL 18: failed") + + # Zabbix repo + zr = results.get("zabbix_repo", {}) + if zr.get("zabbix_repo"): + _ok("Zabbix 7.4 repository: configured") + + # Zabbix packages + zp = results.get("zabbix_packages", {}) + failed_pkgs = [k for k, v in zp.items() if v is False] + if failed_pkgs: + _fail(f"Zabbix packages failed: {', '.join(failed_pkgs)}") + elif zp: + _ok("Zabbix packages: installed") + + # Schema + schema = results.get("database_schema", {}) + if schema.get("schema") is True: + _ok("Database schema: imported") + elif schema.get("schema") is False: + _fail("Database schema: failed") + + # Admin password + admin = results.get("admin_password", {}) + if admin.get("admin_password") is True: + _ok("Admin password: set") + elif admin.get("admin_password") is False: + _fail("Admin password: failed") + + # Server + server = results.get("zabbix_server", {}) + if server.get("server_service"): + _ok("Zabbix server: running") + if server.get("agent_service"): + _ok("Zabbix agent: running") + + # nginx + PHP-FPM + nginx_php = results.get("nginx_php", {}) + if nginx_php.get("nginx"): + _ok("nginx: running") + if nginx_php.get("php_fpm"): + _ok("PHP-FPM: running") + + # TLS + tls = results.get("tls", {}) + cert_state = tls.get("cert", "") + if cert_state in ("exists", "created"): + _ok("TLS certificate: configured") + elif cert_state is False: + _fail("TLS certificate: failed") + + # SELinux + selinux = results.get("selinux", {}) + selinux_failed = [k for k, v in selinux.items() if v is False] + if selinux_failed: + _fail(f"SELinux booleans failed: {', '.join(selinux_failed)}") + elif any(v is True for v in selinux.values()): + _ok("SELinux: configured") + + # Firewall + fw = results.get("firewall", {}) + if fw.get("firewall_active") is False: + _info("Firewall: firewalld not running — skipped") + elif fw.get("firewall_configured"): + _ok("Firewall: configured") + + # Credentials — only reached on the successful deploy path; abort + # paths pass an empty db_password so nothing is shown for a role + # that failed to be created. + if db_password: + hostname = _get_hostname() + print(file=sys.stderr) + print(f" {BOLD}Frontend:{RESET} https://{hostname}/", file=sys.stderr) + print(f" {BOLD}Username:{RESET} Admin", file=sys.stderr) + if zabbix_password: + print(f" {BOLD}Password:{RESET} {zabbix_password}", file=sys.stderr) + else: + print( + f" {BOLD}Password:{RESET} (unchanged — existing deployment)", + file=sys.stderr, + ) + print(f" {BOLD}DB Password:{RESET} {db_password}", file=sys.stderr) + print( + f" {DIM}Store these credentials securely — they are shown only now.{RESET}", + file=sys.stderr, + ) + print(file=sys.stderr) + print( + f" {YELLOW}Note:{RESET} Complete the web-based setup wizard on first access.", + file=sys.stderr, + ) + + if warnings: + print(file=sys.stderr) + for w in warnings: + _warn(w) + + print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + print(f" {BOLD}Total time: {elapsed:.1f}s{RESET}", file=sys.stderr) + print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + print(file=sys.stderr) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Idempotent Zabbix 7.4 deployment with PostgreSQL 18, nginx, PHP-FPM", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--db-password", + type=str, + default=None, + dest="db_password", + help="PostgreSQL zabbix user password (default: generated, or reused from " + "zabbix_server.conf on re-runs; prefer the ZABBIX_DB_PASSWORD env var)", + ) + parser.add_argument( + "--timezone", + type=str, + default="Europe/Prague", + help="PHP timezone (default: Europe/Prague)", + ) + parser.add_argument( + "--zabbix-password", + type=str, + default=None, + dest="zabbix_password", + help="Zabbix admin password (default: generated; prefer the " + "ZABBIX_ADMIN_PASSWORD env var — flags leak into shell history)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview without making changes", + ) + parser.add_argument( + "--uninstall", + action="store_true", + help="Tear down Zabbix deployment", + ) + parser.add_argument( + "--skip-firewall", + action="store_true", + dest="skip_firewall", + help="Skip firewall rules", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION}", + ) + return parser.parse_args(argv) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + """Entry point — deploy or uninstall Zabbix.""" + start_time = time.monotonic() + warnings: list[str] = [] + results: dict[str, Any] = {} + + args = parse_args() + + check_root() + os_id, os_display, version_id = detect_os() + + # Resolve passwords. CLI flags work but leak into shell history and + # `ps` output — prefer the environment variables. On re-runs without a + # supplied DB password, the existing one is reused from + # zabbix_server.conf so the database and the server config stay in sync. + db_password_explicit = bool(args.db_password or os.environ.get("ZABBIX_DB_PASSWORD")) + if args.db_password or args.zabbix_password: + warnings.append( + "Passwords passed as CLI flags are visible in shell history and `ps`" + " — prefer ZABBIX_DB_PASSWORD / ZABBIX_ADMIN_PASSWORD env vars" + ) + db_password = ( + args.db_password + or os.environ.get("ZABBIX_DB_PASSWORD") + or _read_existing_db_password() + or secrets.token_urlsafe(16) + ) + zabbix_password_explicit = bool(args.zabbix_password or os.environ.get("ZABBIX_ADMIN_PASSWORD")) + zabbix_password = ( + args.zabbix_password or os.environ.get("ZABBIX_ADMIN_PASSWORD") or secrets.token_urlsafe(16) + ) + + # Header. + print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + print( + f"{BOLD} Zabbix Deploy v{VERSION} — {os_display} {version_id}{RESET}", + file=sys.stderr, + ) + print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr) + if args.dry_run: + print( + f"\n {YELLOW}{BOLD}DRY RUN — no changes will be made{RESET}", + file=sys.stderr, + ) + if args.uninstall and not args.dry_run: + print( + f"\n {YELLOW}{BOLD}UNINSTALL MODE — all Zabbix components will be removed{RESET}", + file=sys.stderr, + ) + print(file=sys.stderr) + + # ── Uninstall path ── + if args.uninstall: + print(f"\n{BOLD}── Uninstall ──{RESET}", file=sys.stderr) + results["uninstall"] = uninstall(dry_run=args.dry_run) + elapsed = time.monotonic() - start_time + print_summary( + zabbix_password="", + db_password="", + os_display=os_display, + version_id=version_id, + results=results, + warnings=warnings, + elapsed=elapsed, + dry_run=args.dry_run, + uninstall_mode=True, + ) + return + + # ── Deploy path ── + + # 1. Pre-flight checks. + print(f"\n{BOLD}── Pre-flight Checks ──{RESET}", file=sys.stderr) + preflight = preflight_checks(dry_run=args.dry_run) + results["preflight"] = preflight + mem = preflight.get("memory_gb") + if mem is not None and mem < 2.0: + warnings.append( + f"Low memory detected ({mem:.1f} GB). Zabbix + PostgreSQL recommend at least 2 GB RAM." + ) + + # 2. PostgreSQL 18. + print(f"\n{BOLD}── PostgreSQL 18 ──{RESET}", file=sys.stderr) + results["postgresql"] = setup_postgresql( + os_id=os_id, + version_id=version_id, + db_password=db_password, + db_password_sync=db_password_explicit, + dry_run=args.dry_run, + ) + pg = results["postgresql"] + if not pg.get("pg_service") or not pg.get("pg_user") or not pg.get("pg_database"): + warnings.append("PostgreSQL provisioning failed — aborting deployment") + elapsed = time.monotonic() - start_time + print_summary( + zabbix_password="", + db_password="", + os_display=os_display, + version_id=version_id, + results=results, + warnings=warnings, + elapsed=elapsed, + dry_run=args.dry_run, + ) + sys.exit(1) + + # 3. Zabbix Repository. + print(f"\n{BOLD}── Zabbix Repository ──{RESET}", file=sys.stderr) + results["zabbix_repo"] = setup_zabbix_repo( + os_id=os_id, + version_id=version_id, + dry_run=args.dry_run, + ) + + # 4. Zabbix Packages. + print(f"\n{BOLD}── Zabbix Packages ──{RESET}", file=sys.stderr) + results["zabbix_packages"] = setup_zabbix_packages(dry_run=args.dry_run) + + # 5. Database Schema. + print(f"\n{BOLD}── Database Schema ──{RESET}", file=sys.stderr) + results["database_schema"] = setup_database_schema(dry_run=args.dry_run) + schema = results["database_schema"] + if schema.get("schema") is not True: + warnings.append("Database schema import failed — aborting deployment") + elapsed = time.monotonic() - start_time + print_summary( + zabbix_password="", + db_password="", + os_display=os_display, + version_id=version_id, + results=results, + warnings=warnings, + elapsed=elapsed, + dry_run=args.dry_run, + ) + sys.exit(1) + + # 5b. Set the Admin frontend password for real (bcrypt) when the schema + # was just imported or a password was explicitly supplied. On plain + # re-runs it is left untouched and reported as unchanged. + if args.dry_run or schema.get("schema_fresh") or zabbix_password_explicit: + results["admin_password"] = set_admin_password( + zabbix_password=zabbix_password, + dry_run=args.dry_run, + ) + if results["admin_password"].get("admin_password") is False: + warnings.append( + "Admin password could not be set — on a fresh install the" + " default Admin/zabbix credentials are still active" + ) + + # 6. Zabbix Server Config. + print(f"\n{BOLD}── Zabbix Server ──{RESET}", file=sys.stderr) + results["zabbix_server"] = setup_zabbix_server( + db_password=db_password, + dry_run=args.dry_run, + ) + server = results["zabbix_server"] + if server.get("server_service") is False: + warnings.append("Zabbix server failed to start") + if server.get("agent_service") is False: + warnings.append("Zabbix agent failed to start") + + # 7. TLS Certificate (before nginx — the server block references the cert). + print(f"\n{BOLD}── TLS Certificate ──{RESET}", file=sys.stderr) + results["tls"] = setup_tls(dry_run=args.dry_run) + if results["tls"].get("cert") is False: + warnings.append("TLS certificate creation failed — HTTPS frontend will not work") + + # 8. nginx + PHP-FPM. + print(f"\n{BOLD}── nginx + PHP-FPM ──{RESET}", file=sys.stderr) + results["nginx_php"] = setup_nginx_php( + timezone=args.timezone, + dry_run=args.dry_run, + ) + + # 9. SELinux. + print(f"\n{BOLD}── SELinux ──{RESET}", file=sys.stderr) + results["selinux"] = setup_selinux(dry_run=args.dry_run) + for boolean, boolean_ok in results["selinux"].items(): + if boolean_ok is False: + warnings.append(f"SELinux boolean failed: {boolean}") + + # 10. Firewall. + print(f"\n{BOLD}── Firewall ──{RESET}", file=sys.stderr) + results["firewall"] = setup_firewall( + skip_firewall=args.skip_firewall, + dry_run=args.dry_run, + ) + + # Only show the Admin password when it was actually set this run. + admin_password_shown = ( + zabbix_password if results.get("admin_password", {}).get("admin_password") is True else "" + ) + + elapsed = time.monotonic() - start_time + print_summary( + zabbix_password=admin_password_shown, + db_password=db_password, + os_display=os_display, + version_id=version_id, + results=results, + warnings=warnings, + elapsed=elapsed, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + main()