Audit & optimisation serveur

Diagnostiquer le réglage web / PHP / base de données par rapport au matériel

⚠️ Bêta, réservé aux administrateurs expérimentés. Le script ne connaît pas tout de votre serveur (charge réelle, autres services…) : ses sorties sont des indications, à vérifier avant toute modification.

Ce script analyse la configuration d'un serveur Nextcloud : serveur web (Apache/Nginx), PHP/PHP-FPM, base de données (PostgreSQL/MariaDB) et hygiène de sécurité (fail2ban/CrowdSec, mises à jour en attente). Il la compare aux ressources de la machine (RAM, CPU, disque) et propose des optimisations. Il gère les hôtes à plusieurs instances.

🔒 Le script tourne en root mais ne fait que lire. Le résultat reste local (terminal et rapport texte dans /tmp) : rien n'est envoyé au monitor. Relisez-le avant de le lancer.

1. Récupérer le script

A · Depuis cette page

Relisez-le avant de le lancer : c'est un script root, ne lancez jamais un script sans le comprendre.

B · Depuis GitLab (dépôt public)

Le script est public sur GitLab : récupérez-le directement sur le serveur en une commande, pratique pour une flotte. En pipe, passez le chemin et le budget par NC_PATH / NC_RAM_BUDGET_PCT.

Récupérer dans un fichier, recommandé (relire avant de lancer / réutiliser) :

curl -fsSL https://gitlab.com/jp.louvel/ncstatuscheck/-/raw/master/tools/nc-audit.sh -o nc-audit.sh

Ou en une seule commande (pratique en flotte, mais exécute sans relecture) :

curl -fsSL https://gitlab.com/jp.louvel/ncstatuscheck/-/raw/master/tools/nc-audit.sh | sudo bash

Source de confiance (votre dépôt, en HTTPS) et script en lecture seule : risque faible. Pour exécuter exactement la version relue, remplacez master par un tag ou un commit dans l'URL.

2. Dépendances (optionnel, Debian)

L'audit de base tourne avec les outils déjà présents sur un serveur Nextcloud. Pour l'analyse approfondie (recommandations « runtime »), installez ces paquets :

sudo apt install mysqltuner percona-toolkit sysstat
sudo systemctl enable --now sysstat

apache2buddy (Perl, non empaqueté : dimensionnement Apache d'après la RAM réelle) :

sudo wget -qO /usr/local/bin/apache2buddy.pl https://raw.githubusercontent.com/richardforth/apache2buddy/master/apache2buddy.pl
sudo chmod +x /usr/local/bin/apache2buddy.pl

3. Lancer le script

Chemin : 1er argument (… nc-audit.sh /var/www/nextcloud) ou NC_PATH=, sinon auto-détecté. Budget mémoire : NC_RAM_BUDGET_PCT= (100 par défaut = serveur dédié ; à baisser si le serveur héberge d'autres services).

Auto-détection (serveur dédié)
sudo bash nc-audit.sh
Chemin Nextcloud explicite
sudo bash nc-audit.sh /var/www/nextcloud
Serveur partagé / plusieurs instances (budget 50 % de la RAM)
sudo NC_RAM_BUDGET_PCT=50 bash nc-audit.sh /var/www/nextcloud
Depuis GitLab, en une commande
curl -fsSL https://gitlab.com/jp.louvel/ncstatuscheck/-/raw/master/tools/nc-audit.sh | sudo bash
Depuis GitLab, avec chemin + budget (via variables, car en pipe)
curl -fsSL https://gitlab.com/jp.louvel/ncstatuscheck/-/raw/master/tools/nc-audit.sh \
  | sudo NC_RAM_BUDGET_PCT=50 NC_PATH=/var/www/nextcloud bash
Plusieurs instances : répartir le budget PHP par poids (NC_RAM_BUDGET_PCT = budget total)
sudo NC_RAM_BUDGET_PCT=70 NC_INSTANCES="poolA:4,poolB:2,poolC:1" bash nc-audit.sh
Assistant interactif : saisir un poids par pool → cibles pm.max_children + ligne NC_INSTANCES réutilisable
sudo NC_RAM_BUDGET_PCT=70 bash nc-audit.sh --tune-fpm

Script (nc-audit.sh) v1.25

#!/bin/bash
# ==============================================================================
# nc-audit.sh - Audit and optimization of a Nextcloud server (Debian)
# Author : ézéo - Digital cooperative
# License : AGPL-3.0
#
# Analyzes the server configuration and generates recommendations for
# Apache/Nginx, PHP-FPM, PostgreSQL/MariaDB and Nextcloud, taking the
# machine's physical capacity (RAM, CPU) into account.
#
# Distributed by NcStatusCheck (the "Server audit" page). Read before running:
# it runs as root and only READS the configuration (no modifications).
#
# Usage :
#   sudo bash nc-audit.sh [/path/to/nextcloud]
#   sudo bash nc-audit.sh /var/www/nextcloud            # path as argument
#   sudo NC_PATH=/var/www/nextcloud bash nc-audit.sh    # or via variable (handy in a pipe)
#   sudo NC_RAM_BUDGET_PCT=50 bash nc-audit.sh /var/www/nextcloud  # shared server (50% RAM ; default 100)
#   sudo NC_RAM_BUDGET_PCT=70 NC_INSTANCES="poolA:4,poolB:2,poolC:1" bash nc-audit.sh
#       # multi-instance: split the PHP budget across FPM pools by weight → a
#       # target pm.max_children per pool. Here NC_RAM_BUDGET_PCT is the TOTAL
#       # stack budget (all instances), not per-instance.
#   sudo NC_RAM_BUDGET_PCT=70 bash nc-audit.sh --tune-fpm
#       # interactive (terminal only): lists the FPM pools, asks a weight for
#       # each, prints the target pm.max_children + a reusable NC_INSTANCES line.
#   NC_PHP_SHARE_PCT=50  (default 60) — PHP's share of the stack budget (the rest
#       # covers DB + web + OS); lower it on DB-heavy servers. Range 20..90.
#   NC_DB_SHARE_PCT=35  (default 28) — DB's share of the SAME stack budget
#       # (InnoDB buffer pool / PG shared_buffers). Kept low enough that PHP +
#       # DB stays ~85-90% together, leaving room for Apache/Nginx + the OS.
#       # Range 10..70. Raise it on DB-heavy servers (and lower NC_PHP_SHARE_PCT).
# ==============================================================================

# No `-e`: an audit must always run to the end, even if a sub-call fails on a
# missing/atypical layer. We keep -u (catch unset vars) but NOT pipefail:
# `pipefail` + `cmd | grep -q` is a trap — grep -q exits on the first match and
# closes the pipe, the upstream (e.g. `systemctl list-units`, lots of output)
# gets SIGPIPE (141), and pipefail turns that SUCCESSFUL match into a pipeline
# "failure" → flaky false negatives (the famous "PHP-FPM not detected" while it
# IS running). The audit never relies on pipefail (values use ${x:-default}).
set -u

# Script version (bumped by hand) — eases tracking: we know which version
# produced a report and we can spot an outdated script on a server.
AUDIT_VERSION="1.25"

# Current mode (report | json | push). In json/push mode the audit output is
# silent (stdout reserved for JSON) → header() prints progress on stderr.
AUDIT_MODE="report"

# --- Colors and formatting ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

# Result counters
WARN_COUNT=0
CRIT_COUNT=0
OK_COUNT=0
INFO_COUNT=0

# Action plan: every recommend() is recorded with the severity and text of the
# finding it addresses, then REPLAYED in the summary sorted CRIT → WARN → rest.
# No per-case logic — it's a replay of what was already printed inline.
LAST_FINDING_SEV=""
LAST_FINDING_MSG=""
declare -a ACTION_ITEMS=()

# Report file — created via mktemp in main() (unpredictable name + 0600: a
# predictable /tmp path written as root is a classic symlink-attack target,
# and the report is not for other local users' eyes). Empty until then.
REPORT_FILE=""

# --- Utility functions ---

log_raw() {
    echo -e "$1"
    if [ -n "$REPORT_FILE" ]; then
        echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g' >> "$REPORT_FILE"
    fi
}

header() {
    # In json/push mode (silent output), give progress feedback on stderr —
    # without polluting the JSON on stdout.
    [ "${AUDIT_MODE:-report}" != "report" ] && printf '  … %s\n' "$1" >&2
    log_raw ""
    log_raw "${BOLD}${BLUE}═══════════════════════════════════════════════════════════${NC}"
    log_raw "${BOLD}${BLUE}  $1${NC}"
    log_raw "${BOLD}${BLUE}═══════════════════════════════════════════════════════════${NC}"
}

subheader() {
    log_raw ""
    log_raw "${BOLD}${CYAN}--- $1 ---${NC}"
}

ok() {
    log_raw "  ${GREEN}✔ OK${NC}    $1"
    LAST_FINDING_SEV=""; LAST_FINDING_MSG="$1"
    ((OK_COUNT++)) || true
}

warn() {
    log_raw "  ${YELLOW}⚠ WARN${NC}  $1"
    LAST_FINDING_SEV="WARN"; LAST_FINDING_MSG="$1"
    ((WARN_COUNT++)) || true
}

crit() {
    log_raw "  ${RED}✘ CRIT${NC}  $1"
    LAST_FINDING_SEV="CRIT"; LAST_FINDING_MSG="$1"
    ((CRIT_COUNT++)) || true
}

info() {
    log_raw "  ${CYAN}ℹ INFO${NC}  $1"
    LAST_FINDING_SEV="INFO"; LAST_FINDING_MSG="$1"
    ((INFO_COUNT++)) || true
}

recommend() {
    log_raw "  ${YELLOW}→ Recommendation:${NC} $1"
    ACTION_ITEMS+=("${LAST_FINDING_SEV:-INFO}|${LAST_FINDING_MSG}|$1")
}

# Emits the multi-line output of an external tool: indented on screen AND in the
# report (ANSI codes stripped). Usage: some_command 2>&1 | emit
emit() {
    sed 's/\x1b\[[0-9;]*m//g' | while IFS= read -r line; do
        printf '   %s\n' "$line"
        printf '   %s\n' "$line" >> "$REPORT_FILE"
    done
}

# Convert a PHP value (128M, 1G, etc.) to MB. "-1" (unlimited, the common CLI
# default) is passed through as-is: callers must treat it specially — mapping
# it to 0 used to raise a false "memory_limit too low (-1)" CRIT.
php_to_mb() {
    local val="$1"
    val=$(echo "$val" | tr -d ' ')
    if [ "$val" = "-1" ]; then
        echo "-1"
    elif [[ "$val" =~ ^([0-9]+)[gG]$ ]]; then
        echo $(( ${BASH_REMATCH[1]} * 1024 ))
    elif [[ "$val" =~ ^([0-9]+)[mM]$ ]]; then
        echo "${BASH_REMATCH[1]}"
    elif [[ "$val" =~ ^([0-9]+)[kK]$ ]]; then
        echo $(( ${BASH_REMATCH[1]} / 1024 ))
    elif [[ "$val" =~ ^([0-9]+)$ ]]; then
        echo $(( val / 1024 / 1024 ))
    else
        echo "0"
    fi
}

# Read a value from a config file (ini-style)
get_ini_value() {
    local file="$1"
    local key="$2"
    local default="${3:-}"
    local val
    val=$(grep -E "^\s*${key}\s*=" "$file" 2>/dev/null | tail -1 | cut -d'=' -f2 | tr -d ' "'"'"'' | tr -d '\r')
    echo "${val:-$default}"
}

# Read an effective PHP value
get_php_ini_value() {
    local key="$1"
    local default="${2:-}"
    local val
    val=$(php -r "echo ini_get('$key');" 2>/dev/null)
    echo "${val:-$default}"
}

# Read a value from a PostgreSQL config
get_pg_setting() {
    local key="$1"
    local default="${2:-}"
    local val
    val=$(sudo -u postgres psql -t -c "SHOW $key;" 2>/dev/null | tr -d ' ')
    echo "${val:-$default}"
}

# Read a value from MariaDB
get_mysql_setting() {
    local key="$1"
    local default="${2:-}"
    local val
    val=$(mysql -N -e "SHOW VARIABLES LIKE '$key';" 2>/dev/null | awk '{print $2}')
    echo "${val:-$default}"
}

# Convert a PostgreSQL value to MB
pg_to_mb() {
    local val="$1"
    if [[ "$val" =~ ^([0-9]+)GB$ ]]; then
        echo $(( ${BASH_REMATCH[1]} * 1024 ))
    elif [[ "$val" =~ ^([0-9]+)MB$ ]]; then
        echo "${BASH_REMATCH[1]}"
    elif [[ "$val" =~ ^([0-9]+)kB$ ]]; then
        echo $(( ${BASH_REMATCH[1]} / 1024 ))
    elif [[ "$val" =~ ^([0-9]+)$ ]]; then
        # In 8kB pages by default
        echo $(( ${BASH_REMATCH[1]} * 8 / 1024 ))
    else
        echo "0"
    fi
}

# Determine the PHP SAPI that ACTUALLY serves Nextcloud (fpm > mod_php apache > cli)
# and its version. CLI values often differ (OPcache off in CLI, memory_limit…),
# so we read the right SAPI's config rather than via `php -r ini_get`.
PHP_AUDIT_VER=""
PHP_AUDIT_SAPI=""

detect_php_target() {
    PHP_AUDIT_VER=$(php -v 2>/dev/null | head -1 | awk '{print $2}' | grep -oP '^\d+\.\d+')
    # A php-fpm systemd service being installed/enabled is NOT proof Apache
    # actually routes .php requests to it — mod_php and php-fpm commonly
    # coexist on a box (fpm left running unused, or serving another vhost).
    # A php module actually LOADED in Apache is the authoritative signal, so
    # check it first; only fall back to fpm when mod_php isn't loaded.
    local apache_mods mod_php_ver
    apache_mods=$(apache2ctl -M 2>/dev/null)
    if echo "$apache_mods" | grep -qiE "php[0-9._]*_module"; then
        # mod_php's version can differ from the CLI's (e.g. libapache2-mod-php8.2
        # kept alongside a 8.3 CLI): trust the loaded module's own version
        # (Debian names it phpX.Y_module) over `php -v` when it is readable.
        mod_php_ver=$(echo "$apache_mods" | grep -oiE 'php[0-9]+(\.[0-9]+)?_module' | grep -oE '[0-9]+\.[0-9]+' | head -1)
        [ -n "$mod_php_ver" ] && [ -d "/etc/php/${mod_php_ver}/apache2" ] && PHP_AUDIT_VER="$mod_php_ver"
        if [ -d "/etc/php/${PHP_AUDIT_VER}/apache2" ]; then
            PHP_AUDIT_SAPI="apache2"
            return
        fi
    fi
    if systemctl list-units --type=service 2>/dev/null | grep -q "php.*fpm" && [ -d "/etc/php/${PHP_AUDIT_VER}/fpm" ]; then
        PHP_AUDIT_SAPI="fpm"
    else
        PHP_AUDIT_SAPI="cli"
    fi
}

# Read an EFFECTIVE ini directive from the target SAPI's files:
# php.ini + conf.d/*.ini (+ pool.d/*.conf for fpm, php_admin_value/php_value)
# + the Nextcloud webroot's .user.ini (PHP_INI_PERDIR/ALL directives — this is
# where Nextcloud installs commonly set memory_limit/upload_max_filesize/
# post_max_size/max_execution_time, per its own documentation), read last so
# it wins over the pool defaults, matching what FPM actually applies per-request.
# Falls back to `php -r ini_get` (CLI) if the SAPI config can't be found.
get_effective_ini() {
    local key="$1"
    local default="${2:-}"
    local ver="${PHP_AUDIT_VER}" sapi="${PHP_AUDIT_SAPI}"
    local kre="${key//./\\.}"
    local val=""

    if [ -n "$ver" ] && [ -n "$sapi" ] && [ "$sapi" != "cli" ] && [ -d "/etc/php/${ver}/${sapi}" ]; then
        local files=()
        [ -f "/etc/php/${ver}/${sapi}/php.ini" ] && files+=("/etc/php/${ver}/${sapi}/php.ini")
        while IFS= read -r f; do files+=("$f"); done \
            < <(find "/etc/php/${ver}/${sapi}/conf.d/" -name '*.ini' 2>/dev/null | sort)
        if [ "$sapi" = "fpm" ]; then
            while IFS= read -r f; do files+=("$f"); done \
                < <(find "/etc/php/${ver}/fpm/pool.d/" -name '*.conf' 2>/dev/null | sort)
            [ -n "${NC_DETECTED_PATH:-}" ] && [ -f "${NC_DETECTED_PATH}/.user.ini" ] \
                && files+=("${NC_DETECTED_PATH}/.user.ini")
        fi
        local f line v admin_val=""
        for f in "${files[@]}"; do
            # ini: "key = value" ; fpm pool: "php_value[key] = value" / "php_admin_value[key] = value"
            line=$(grep -E "^[[:space:]]*(php_(admin_)?value\[)?${kre}(\])?[[:space:]]*=" "$f" 2>/dev/null \
                   | grep -vE '^[[:space:]]*;' | tail -1)
            if [ -n "$line" ]; then
                v=$(echo "$line" | sed -E 's/^[^=]*=[[:space:]]*//; s/[[:space:]]*;.*$//; s/[[:space:]]*$//' | tr -d '"')
                # php_admin_value is FINAL in PHP: .user.ini (read after the
                # pool files) cannot override it, so track it separately
                # instead of letting "last file wins" report the wrong value.
                if echo "$line" | grep -qE '^[[:space:]]*php_admin_value\['; then
                    admin_val="$v"
                else
                    val="$v"
                fi
            fi
        done
        [ -n "$admin_val" ] && val="$admin_val"
    fi

    if [ -z "$val" ]; then
        val=$(php -r "echo ini_get('$key');" 2>/dev/null)
    fi
    echo "${val:-$default}"
}

# Average real RSS (MB) of the php-fpm WORKER processes — masters run as root
# and are excluded. Sizing pm.max_children on a measured figure beats the flat
# 50 MB guess (Nextcloud workers commonly sit at 60-120 MB). NB: RSS counts the
# shared OPcache segment in EVERY worker, so the average slightly OVER-estimates
# → the resulting ceilings are conservative (safe). Falls back to 50 when fewer
# than 3 workers are running (an average over 1-2 idle workers is meaningless).
fpm_avg_worker_mb() {
    local avg
    avg=$(ps -eo rss=,user=,comm= 2>/dev/null \
          | awk '$3 ~ /^php-fpm/ && $2 != "root" {s+=$1; n++} END {if (n>=3) printf "%d", s/n/1024}')
    if [ -n "$avg" ] && [ "$avg" -ge 30 ] 2>/dev/null; then
        echo "$avg"
    else
        echo 50
    fi
}

# ==============================================================================
# SECTION 0 : Dependencies (required vs optional + how to install)
# ==============================================================================

audit_dependencies() {
    header "0. DEPENDENCIES"
    info "The script runs with the standard tools. The packages below enrich it — it works without them, but better with."

    subheader "Core tools (standard analyses)"
    local c
    for c in php awk sed grep find df free nproc; do
        if command -v "$c" >/dev/null 2>&1; then
            ok "${c} present"
        else
            crit "${c} missing — some analyses will be limited"
        fi
    done
    # util-linux: disk type detection (SSD/HDD)
    if command -v lsblk >/dev/null 2>&1 && command -v findmnt >/dev/null 2>&1; then
        ok "lsblk/findmnt present (SSD/HDD detection)"
    else
        warn "lsblk/findmnt missing — SSD/HDD detection disabled"
        recommend "Install: apt install util-linux"
    fi
    # timeout: anti-hang guard for the deep-analysis mode
    if ! command -v timeout >/dev/null 2>&1; then
        warn "timeout missing (coreutils) — the deep-analysis mode loses its anti-hang guard"
    fi

    subheader "Optional tools — deep analysis"
    info "Not required, but with them the audit goes FURTHER (recommendations based on real behavior):"
    local missing_apt=() missing_a2b=0 missing_sysstat=0
    if command -v mysqltuner >/dev/null 2>&1; then
        ok "mysqltuner present — runtime MySQL/MariaDB recommendations"
    else
        info "mysqltuner missing → without it: no runtime MySQL/MariaDB recommendations"
        missing_apt+=("mysqltuner")
    fi
    if command -v pt-variable-advisor >/dev/null 2>&1; then
        ok "percona-toolkit present — advice on MySQL variables"
    else
        info "percona-toolkit missing → without it: no advice on MySQL variables"
        missing_apt+=("percona-toolkit")
    fi
    if command -v sar >/dev/null 2>&1 || command -v iostat >/dev/null 2>&1; then
        ok "sysstat present — CPU/RAM history + disk I/O (sar/iostat)"
    else
        info "sysstat missing → without it: no CPU/RAM history nor disk I/O"
        missing_apt+=("sysstat"); missing_sysstat=1
    fi
    if command -v apache2buddy >/dev/null 2>&1 || [ -f /usr/local/bin/apache2buddy.pl ]; then
        ok "apache2buddy present — Apache sizing based on real RAM"
    else
        info "apache2buddy missing → without it: no Apache sizing (real RAM)"
        missing_a2b=1
    fi
    if command -v needrestart >/dev/null 2>&1; then
        ok "needrestart present — pending kernel reboot + services on stale libraries (section 8)"
    else
        info "needrestart missing → without it: no detection of services running on outdated libraries / kernel"
        missing_apt+=("needrestart")
    fi

    if [ "${#missing_apt[@]}" -gt 0 ] || [ "$missing_a2b" -eq 1 ]; then
        log_raw ""
        log_raw "  ${BOLD}${CYAN}➜ For more in-depth recommendations, install then re-run the audit:${NC}"
        [ "${#missing_apt[@]}" -gt 0 ] && log_raw "      ${GREEN}apt install ${missing_apt[*]}${NC}"
        [ "$missing_sysstat" -eq 1 ] && log_raw "      ${GREEN}systemctl enable --now sysstat${NC}   # enables history collection"
        if [ "$missing_a2b" -eq 1 ]; then
            log_raw "      ${CYAN}# apache2buddy (Perl, not packaged — the old \"apachebuddy\" is abandoned):${NC}"
            log_raw "      ${GREEN}sudo wget -qO /usr/local/bin/apache2buddy.pl https://raw.githubusercontent.com/richardforth/apache2buddy/master/apache2buddy.pl${NC}"
            log_raw "      ${GREEN}sudo chmod +x /usr/local/bin/apache2buddy.pl${NC}"
        fi
    else
        ok "All optional tools are present — maximum deep analysis."
    fi
}

# ==============================================================================
# SECTION 1 : Server capacity
# ==============================================================================

audit_server() {
    header "1. SERVER CAPACITY"

    # --- CPU ---
    subheader "CPU"
    local cpu_cores
    cpu_cores=$(nproc)
    local cpu_model
    cpu_model=$(grep -m1 "model name" /proc/cpuinfo | cut -d: -f2 | xargs)
    info "Processor: ${cpu_model}"
    info "Available cores: ${cpu_cores}"

    # --- RAM ---
    subheader "Memory"
    local total_ram_kb total_ram_mb available_ram_mb swap_total_mb
    total_ram_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}')
    total_ram_mb=$((total_ram_kb / 1024))
    available_ram_mb=$(grep MemAvailable /proc/meminfo | awk '{print $2}')
    available_ram_mb=$((available_ram_mb / 1024))
    swap_total_mb=$(grep SwapTotal /proc/meminfo | awk '{print $2}')
    swap_total_mb=$((swap_total_mb / 1024))

    info "Total RAM: ${total_ram_mb} MB"
    info "Available RAM: ${available_ram_mb} MB"
    info "Swap: ${swap_total_mb} MB"

    if [ "$total_ram_mb" -lt 2048 ]; then
        crit "Less than 2 GB of RAM — Nextcloud may be slow"
        recommend "2 GB minimum recommended, ideally 4 GB+"
    elif [ "$total_ram_mb" -lt 4096 ]; then
        warn "Less than 4 GB of RAM — sufficient but limited"
    else
        ok "Sufficient RAM (${total_ram_mb} MB)"
    fi

    if [ "$swap_total_mb" -eq 0 ]; then
        warn "No swap configured"
        recommend "Add at least 1-2 GB of swap as a safety net"
    else
        ok "Swap configured (${swap_total_mb} MB)"
    fi

    # Swappiness: on a host with a database, we want to avoid swapping the cache.
    local swappiness
    swappiness=$(cat /proc/sys/vm/swappiness 2>/dev/null || echo "")
    if [ -n "$swappiness" ]; then
        info "vm.swappiness = ${swappiness}"
        if [ "$swappiness" -gt 30 ] 2>/dev/null; then
            warn "vm.swappiness high (${swappiness}) for an application/DB server"
            recommend "Lower to 10 (even 1 on a dedicated DB host): vm.swappiness=10 in /etc/sysctl.d/"
        elif [ "$swappiness" -gt 10 ] 2>/dev/null; then
            ok "vm.swappiness = ${swappiness} (fine ; ≤10 ideal on a DB host)"
        else
            ok "vm.swappiness = ${swappiness}"
        fi
    fi

    # --- Disk ---
    subheader "Storage"
    local disk_usage disk_percent
    disk_usage=$(df -h / | awk 'NR==2 {print $2 " total, " $3 " used, " $4 " free"}')
    disk_percent=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
    info "Partition /: ${disk_usage}"

    if [ "$disk_percent" -gt 90 ]; then
        crit "Disk used at ${disk_percent}% — almost full!"
        recommend "Free up space or grow the disk"
    elif [ "$disk_percent" -gt 80 ]; then
        warn "Disk used at ${disk_percent}%"
        recommend "Monitor disk space, plan for a resize"
    else
        ok "Disk space OK (${disk_percent}%)"
    fi

    # Disk type (SSD/HDD): drives the database I/O recommendations.
    # rotational = 0 → SSD/NVMe, 1 → mechanical disk.
    export SERVER_DISK_SSD="unknown"
    local root_src rota
    root_src=$(findmnt -no SOURCE / 2>/dev/null || true)
    if [ -n "$root_src" ]; then
        rota=$(lsblk -no ROTA "$root_src" 2>/dev/null | grep -oE '[01]' | head -1)
        if [ "$rota" = "0" ]; then
            export SERVER_DISK_SSD="true"
            ok "SSD/NVMe disk detected (non-rotational)"
        elif [ "$rota" = "1" ]; then
            export SERVER_DISK_SSD="false"
            info "Mechanical disk (HDD) detected"
        else
            info "Disk type undetermined"
        fi
    fi

    # Check the Nextcloud data directory if found
    if [ -n "${NC_DATA_DIR:-}" ] && [ -d "$NC_DATA_DIR" ]; then
        local data_disk data_percent
        data_disk=$(df -h "$NC_DATA_DIR" | awk 'NR==2 {print $2 " total, " $3 " used, " $4 " free"}')
        data_percent=$(df "$NC_DATA_DIR" | awk 'NR==2 {print $5}' | tr -d '%')
        info "Data partition (${NC_DATA_DIR}): ${data_disk}"
        if [ "$data_percent" -gt 85 ]; then
            warn "Data partition used at ${data_percent}%"
        fi
    fi

    # --- Allocation context: dedicated or shared? ---
    subheader "Memory allocation context"
    local budget_mb=$(( total_ram_mb * NC_RAM_BUDGET_PCT / 100 ))
    export SERVER_RAM_MB="$total_ram_mb"
    export SERVER_RAM_BUDGET_MB="$budget_mb"
    export SERVER_CPU_CORES="$cpu_cores"
    info "RAM budget for the Nextcloud stack: ${NC_RAM_BUDGET_PCT}% = ${budget_mb} MB (tunable via NC_RAM_BUDGET_PCT=…)"

    # Signs that the server ALSO hosts something else (→ memory recos = ceilings).
    local shared_signals=()

    # Other application databases (excluding system databases)?
    if mysql -N -e "SELECT 1" >/dev/null 2>&1; then
        local nb_mysql
        nb_mysql=$(mysql -N -e "SHOW DATABASES" 2>/dev/null \
            | grep -vE '^(information_schema|performance_schema|mysql|sys)$' | grep -c .)
        [ "${nb_mysql:-0}" -gt 1 ] 2>/dev/null && shared_signals+=("${nb_mysql} MySQL/MariaDB databases")
    fi
    # Guard on the service being active first: `sudo -u postgres` on a host with
    # no postgres system user (MySQL-only, the common case) logs an "unknown
    # user" auth failure on every single audit run for nothing.
    if systemctl is-active --quiet postgresql 2>/dev/null && sudo -u postgres psql -tAc "SELECT 1" >/dev/null 2>&1; then
        local nb_pg
        nb_pg=$(sudo -u postgres psql -tAc \
            "SELECT count(*) FROM pg_database WHERE datistemplate=false AND datname<>'postgres'" 2>/dev/null || echo 0)
        [ "${nb_pg:-0}" -gt 1 ] 2>/dev/null && shared_signals+=("${nb_pg} PostgreSQL databases")
    fi

    # Multiple web vhosts? (we exclude the Debian default vhosts)
    # IMPORTANT: always print a number (even if the directory doesn't exist),
    # otherwise the $(( a + b )) arithmetic breaks on an empty value.
    count_vhosts() {
        local n=0
        if [ -d "$1" ]; then
            n=$(find "$1" -maxdepth 1 \( -type l -o -type f \) -printf '%f\n' 2>/dev/null \
                | grep -viE '^(000-default(\.conf)?|default(\.conf)?|default-ssl(\.conf)?)$' \
                | grep -c .)
        fi
        echo "${n:-0}"
    }
    local va vn nb_vhosts
    va=$(count_vhosts /etc/apache2/sites-enabled)
    vn=$(count_vhosts /etc/nginx/sites-enabled)
    nb_vhosts=$(( ${va:-0} + ${vn:-0} ))
    # A single Nextcloud commonly has TWO enabled vhosts (HTTP→HTTPS redirect +
    # HTTPS, e.g. certbot's -le-ssl pair) — only 3+ suggests actual cohabitation.
    [ "$nb_vhosts" -gt 2 ] && shared_signals+=("${nb_vhosts} web vhosts (excluding defaults)")

    if [ "${#shared_signals[@]}" -gt 0 ]; then
        local signals_joined
        signals_joined=$(printf '%s, ' "${shared_signals[@]}")
        signals_joined="${signals_joined%, }"
        warn "Potentially shared server: ${signals_joined}"
        if [ "$NC_RAM_BUDGET_PCT" -ge 100 ]; then
            recommend "The memory recos assume a DEDICATED server. Re-run with a suitable budget, e.g.: sudo NC_RAM_BUDGET_PCT=50 bash $0"
        else
            ok "Reduced budget already applied (${NC_RAM_BUDGET_PCT}%) — recos adjusted for sharing"
        fi
    else
        ok "No obvious sign of sharing (recommendations in dedicated mode)"
    fi
}

# ==============================================================================
# SECTION 2 : Nextcloud
# ==============================================================================

audit_nextcloud() {
    header "2. NEXTCLOUD"

    # Look for the Nextcloud installation
    local nc_path=""
    if [ -n "${NC_CUSTOM_PATH:-}" ] && [ -f "${NC_CUSTOM_PATH}/occ" ]; then
        nc_path="$NC_CUSTOM_PATH"
    fi

    if [ -z "$nc_path" ]; then
        for candidate in /var/www/nextcloud /var/www/html/nextcloud /var/www/*/nextcloud /srv/nextcloud; do
            if [ -f "${candidate}/occ" ] 2>/dev/null; then
                nc_path="$candidate"
                break
            fi
        done
    fi

    # Try via the Apache/Nginx config
    if [ -z "$nc_path" ]; then
        nc_path=$(grep -rl "occ" /var/www/ 2>/dev/null | head -1 | xargs dirname 2>/dev/null || true)
    fi

    if [ -z "$nc_path" ] || [ ! -f "${nc_path}/occ" ]; then
        warn "Nextcloud installation not found automatically"
        info "Specify the path as an argument: $0 /path/to/nextcloud  (or NC_PATH=… variable)"
        return
    fi

    info "Nextcloud path: ${nc_path}"
    export NC_DETECTED_PATH="$nc_path"

    local nc_user
    nc_user=$(stat -c '%U' "${nc_path}/occ")
    export NC_DETECTED_USER="$nc_user"

    # Version
    subheader "Version and status"
    local nc_version
    nc_version=$(sudo -u "$nc_user" php "${nc_path}/occ" status --output=json 2>/dev/null | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo is_array($j)?($j["versionstring"]??"?"):"?";' 2>/dev/null || echo "unknown")
    info "Nextcloud version: ${nc_version}"

    # Data directory
    local nc_datadir
    nc_datadir=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get datadirectory 2>/dev/null || echo "")
    if [ -n "$nc_datadir" ]; then
        info "Data directory: ${nc_datadir}"
        export NC_DATA_DIR="$nc_datadir"
    fi

    # Database type used by Nextcloud → targets the relevant DB audit (on a shared
    # host, the other engine is audited but flagged "not used by NC").
    local nc_dbtype
    nc_dbtype=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get dbtype 2>/dev/null || echo "")
    if [ -n "$nc_dbtype" ]; then
        export NC_DBTYPE="$nc_dbtype"
        case "$nc_dbtype" in
            pgsql)   info "Database used by Nextcloud: PostgreSQL" ;;
            mysql)   info "Database used by Nextcloud: MySQL/MariaDB" ;;
            sqlite3) warn "Database used by Nextcloud: SQLite — discouraged in production"
                     recommend "Migrate to PostgreSQL or MariaDB (occ db:convert-type)" ;;
            *)       info "Database used by Nextcloud: ${nc_dbtype}" ;;
        esac
    fi

    # Cron
    subheader "Scheduled tasks (cron)"
    local cron_mode
    cron_mode=$(sudo -u "$nc_user" php "${nc_path}/occ" config:app:get core backgroundjobs_mode 2>/dev/null || echo "unknown")
    if [ "$cron_mode" = "cron" ]; then
        ok "System cron mode (recommended)"
    elif [ "$cron_mode" = "ajax" ]; then
        crit "AJAX mode — strongly discouraged in production"
        recommend "Switch to system cron: occ background:cron"
    elif [ "$cron_mode" = "webcron" ]; then
        warn "Webcron mode — system cron is preferable"
        recommend "Switch to system cron: occ background:cron"
    else
        warn "Cron mode: ${cron_mode}"
    fi

    # Check that the cron is running — match THIS instance's cron.php by full
    # path: on a multi-instance host, another instance's entry must not
    # validate this one (a generic "cron.php" grep did exactly that).
    if crontab -u "$nc_user" -l 2>/dev/null | grep -qF "${nc_path}/cron.php"; then
        ok "Crontab configured for ${nc_user}"
    elif grep -rqsF "${nc_path}/cron.php" /etc/cron.d/ /etc/crontab 2>/dev/null; then
        ok "Nextcloud cron configured (/etc/cron.d or /etc/crontab)"
    elif systemctl is-active --quiet nextcloud-cron.timer 2>/dev/null; then
        ok "Nextcloud cron via systemd timer"
    elif crontab -u "$nc_user" -l 2>/dev/null | grep -q "cron\.php" \
         || grep -rqs "cron\.php" /etc/cron.d/ /etc/crontab 2>/dev/null; then
        warn "A cron.php entry exists but not for ${nc_path} (another instance's? path changed?)"
        recommend "Check the cron task: */5 * * * * php -f ${nc_path}/cron.php"
    else
        warn "No Nextcloud crontab detected (cron mode active but entry not found)"
        recommend "Check the cron task: */5 * * * * php -f ${nc_path}/cron.php"
    fi

    # Memory cache
    subheader "Cache and performance"
    local memcache_local memcache_distributed memcache_locking
    memcache_local=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get memcache.local 2>/dev/null || echo "")
    memcache_distributed=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get memcache.distributed 2>/dev/null || echo "")
    memcache_locking=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get memcache.locking 2>/dev/null || echo "")

    if [[ "$memcache_local" == *"APCu"* ]]; then
        ok "memcache.local: APCu (recommended)"
    else
        warn "memcache.local not configured or not optimal (${memcache_local:-none})"
        recommend "Configure 'memcache.local' => '\\OC\\Memcache\\APCu' in config.php"
    fi

    if [[ "$memcache_locking" == *"Redis"* ]]; then
        ok "memcache.locking: Redis (recommended)"
    else
        warn "memcache.locking not configured on Redis (${memcache_locking:-none})"
        recommend "Install Redis and configure 'memcache.locking' => '\\OC\\Memcache\\Redis'"
    fi

    if [ -n "$memcache_distributed" ] && [[ "$memcache_distributed" == *"Redis"* ]]; then
        ok "memcache.distributed: Redis"
    else
        info "memcache.distributed not configured (optional on a single-server instance)"
    fi

    # APCu in CLI: cron.php and occ run in CLI where apc.enable_cli defaults
    # to 0 → "Memcache \OC\Memcache\APCu not available" failures on background
    # jobs. Classic silent breakage when memcache.local is APCu.
    if [[ "$memcache_local" == *"APCu"* ]]; then
        local apc_cli
        apc_cli=$(php -r "echo ini_get('apc.enable_cli');" 2>/dev/null)
        if [ "$apc_cli" = "1" ]; then
            ok "apc.enable_cli = 1 (APCu available to cron.php/occ)"
        else
            warn "apc.enable_cli disabled in CLI while memcache.local uses APCu — cron.php and occ can fail"
            recommend "Set apc.enable_cli = 1 (e.g. in /etc/php/*/mods-available/apcu.ini, applies to the CLI SAPI)"
        fi
    fi

    # Redis: check that it REALLY responds (beyond the NC config).
    # Socket-aware: NC often uses a unix socket → a TCP ping would wrongly fail.
    subheader "Redis"
    local nc_uses_redis="no"
    if [[ "$memcache_local" == *"Redis"* || "$memcache_locking" == *"Redis"* || "$memcache_distributed" == *"Redis"* ]]; then
        nc_uses_redis="yes"
    fi
    if ! command -v redis-cli >/dev/null 2>&1; then
        if [ "$nc_uses_redis" = "yes" ]; then
            crit "Nextcloud is configured on Redis but redis-cli/Redis is not found"
            recommend "Install Redis: apt install redis-server"
        else
            info "Redis not installed (Nextcloud is not configured for Redis)"
        fi
    else
        local r_host r_port r_pass ping_out
        r_host=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get redis host 2>/dev/null || echo "")
        r_port=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get redis port 2>/dev/null || echo "")
        # Auth-aware ping: with requirepass, an unauthenticated ping returns
        # NOAUTH → false "does not respond". Password via REDISCLI_AUTH (env),
        # never on argv (visible in /proc to any local user).
        r_pass=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get redis password 2>/dev/null || echo "")
        local -a redis_cmd=(redis-cli)
        if [[ "$r_host" == /* ]]; then
            redis_cmd+=(-s "$r_host")
        elif [ -n "$r_host" ]; then
            redis_cmd+=(-h "$r_host" -p "${r_port:-6379}")
        fi
        if [ -n "$r_pass" ]; then
            ping_out=$(REDISCLI_AUTH="$r_pass" "${redis_cmd[@]}" ping 2>/dev/null)
        else
            ping_out=$("${redis_cmd[@]}" ping 2>/dev/null)
        fi
        if echo "$ping_out" | grep -q "PONG"; then
            ok "Redis responds (PONG)"
            # Eviction policy: anything but noeviction may evict Nextcloud's
            # FILE LOCKS under memory pressure (the lock keys have a TTL, so
            # even volatile-* policies can drop them) → locking corruption risk.
            if [[ "$memcache_locking" == *"Redis"* ]]; then
                local r_pol
                if [ -n "$r_pass" ]; then
                    r_pol=$(REDISCLI_AUTH="$r_pass" "${redis_cmd[@]}" config get maxmemory-policy 2>/dev/null | tail -1)
                else
                    r_pol=$("${redis_cmd[@]}" config get maxmemory-policy 2>/dev/null | tail -1)
                fi
                case "$r_pol" in
                    "")         : ;;  # CONFIG GET disabled/renamed — can't tell
                    noeviction) ok "Redis maxmemory-policy = noeviction (file locks safe from eviction)" ;;
                    *)          warn "Redis maxmemory-policy = ${r_pol} — under memory pressure Redis may evict Nextcloud file locks"
                                recommend "Use maxmemory-policy noeviction on the Redis serving memcache.locking (and size maxmemory accordingly)" ;;
                esac
            fi
        elif systemctl is-active --quiet redis-server 2>/dev/null || systemctl is-active --quiet redis 2>/dev/null; then
            warn "Redis active but does not respond to ping (host/port/socket or auth?)"
            recommend "Check the Redis connection used by Nextcloud (the 'redis' key in config.php)"
        elif [ "$nc_uses_redis" = "yes" ]; then
            crit "Nextcloud configured on Redis but Redis does not respond / is inactive"
            recommend "Start Redis: systemctl start redis-server"
        else
            info "Redis installed but inactive (not used by Nextcloud)"
        fi
    fi

    # Nextcloud's own setup checks (occ setupchecks, NC 28+): the audit rules
    # maintained by upstream itself (reverse-proxy headers, .well-known, PHP,
    # DB, caches…). Read-only; it may probe its own HTTPS endpoints → timeout.
    subheader "Nextcloud integrated setup checks (occ setupchecks)"
    local sc_out sc_rc sc_err sc_warn
    sc_out=$(timeout 120 sudo -u "$nc_user" php "${nc_path}/occ" setupchecks 2>&1)
    sc_rc=$?
    if echo "$sc_out" | grep -qi "is not defined"; then
        info "occ setupchecks unavailable (Nextcloud < 28) — skipped"
    elif [ -z "$sc_out" ]; then
        info "occ setupchecks produced no output — skipped"
    else
        sc_err=$(echo "$sc_out" | grep -c "✗")
        sc_warn=$(echo "$sc_out" | grep -c "⚠")
        if [ "$(( ${sc_err:-0} + ${sc_warn:-0} ))" -gt 0 ]; then
            warn "occ setupchecks: ${sc_err:-0} error(s), ${sc_warn:-0} warning(s) — Nextcloud's own findings:"
            echo "$sc_out" | grep -E "✗|⚠" | head -n 20 | emit
        elif [ "$sc_rc" -ne 0 ]; then
            info "occ setupchecks exited ${sc_rc} without reporting findings — run it manually to check"
        else
            ok "occ setupchecks: no errors or warnings"
        fi
    fi

    # Check for missing indices — READ-ONLY via --dry-run. occ announces work
    # as "Adding <name> index…" / CREATE INDEX SQL (the word "missing" is not
    # guaranteed), and an occ crash (stack trace) must not read as "OK":
    # test the exit code first.
    local missing_indices idx_rc
    missing_indices=$(sudo -u "$nc_user" php "${nc_path}/occ" db:add-missing-indices --dry-run 2>&1)
    idx_rc=$?
    if [ "$idx_rc" -ne 0 ]; then
        info "db:add-missing-indices check inconclusive (occ exited ${idx_rc})"
    elif echo "$missing_indices" | grep -qiE '\bmissing\b|adding .*(index|indices)|create (unique )?index'; then
        warn "Missing database indices detected"
        recommend "Run: sudo -u ${nc_user} php ${nc_path}/occ db:add-missing-indices"
    else
        ok "Database indices OK"
    fi

    # Missing primary keys — READ-ONLY via --dry-run (same caveats as above).
    # NB: db:add-missing-columns has no reliable dry-run and WOULD MODIFY the database,
    # so we don't run it here (the audit stays strictly non-destructive).
    local missing_pk pk_rc
    missing_pk=$(sudo -u "$nc_user" php "${nc_path}/occ" db:add-missing-primary-keys --dry-run 2>&1)
    pk_rc=$?
    if [ "$pk_rc" -ne 0 ]; then
        info "db:add-missing-primary-keys check inconclusive (occ exited ${pk_rc})"
    elif echo "$missing_pk" | grep -qiE '\bmissing\b|add(ing)? .*primary key'; then
        warn "Missing database primary keys"
        recommend "Run: sudo -u ${nc_user} php ${nc_path}/occ db:add-missing-primary-keys"
    else
        ok "Database primary keys OK"
    fi

    # Default phone region
    local phone_region
    phone_region=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get default_phone_region 2>/dev/null || echo "")
    if [ -z "$phone_region" ]; then
        warn "default_phone_region not configured"
        recommend "Add 'default_phone_region' => 'FR' in config.php"
    else
        ok "default_phone_region: ${phone_region}"
    fi

    # Maintenance window
    local maint_window
    maint_window=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get maintenance_window_start 2>/dev/null || echo "")
    if [ -z "$maint_window" ]; then
        warn "maintenance_window_start not configured"
        recommend "Add 'maintenance_window_start' => 1 (1am UTC) in config.php"
    else
        ok "Maintenance window: ${maint_window}h UTC"
    fi

    # Nextcloud logs: only if log_type=file, and at the REAL path ('logfile' key
    # or datadirectory/nextcloud.log) — no hard-coded path.
    subheader "Nextcloud logs"
    local log_type log_file err_count
    log_type=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get log_type 2>/dev/null || echo "file")
    if [ "${log_type:-file}" != "file" ]; then
        info "Logs sent to \"${log_type}\" (not a file) — scan not applicable"
    else
        log_file=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get logfile 2>/dev/null || echo "")
        [ -z "$log_file" ] && [ -n "${NC_DATA_DIR:-}" ] && log_file="${NC_DATA_DIR}/nextcloud.log"
        if [ -n "$log_file" ] && [ -f "$log_file" ]; then
            # NC logs in JSON: level 3=error, 4=fatal (text fallback ERROR/Fatal).
            # Bounded to the last 2000 lines (recency + performance).
            err_count=$(tail -n 2000 "$log_file" 2>/dev/null | grep -cE '"level":(3|4)|\bERROR\b|\bFatal\b' || true)
            err_count=${err_count:-0}
            if [ "$err_count" -gt 0 ]; then
                warn "${err_count} error(s)/fatal(s) in the last 2000 log lines"
                recommend "Inspect: tail -n 50 ${log_file}"
            else
                ok "No recent errors in ${log_file}"
            fi
        else
            info "Nextcloud log file not found (${log_file:-not set})"
        fi
    fi
}

# ==============================================================================
# SECTION 3 : PHP / PHP-FPM
# ==============================================================================

audit_php() {
    header "3. PHP / PHP-FPM"

    if ! command -v php &>/dev/null; then
        crit "PHP not found"
        return
    fi

    local php_version
    php_version=$(php -v 2>/dev/null | head -1 | awk '{print $2}' || echo "unknown")
    local php_major_minor
    php_major_minor=$(echo "$php_version" | grep -oP '^\d+\.\d+')
    info "PHP version: ${php_version}"

    # Target the config of the SAPI that actually serves Nextcloud (FPM/apache), not the CLI.
    detect_php_target
    if [ "$PHP_AUDIT_SAPI" = "cli" ]; then
        info "PHP config read: CLI (neither PHP-FPM nor mod_php detected — values may differ from the web)"
    else
        info "PHP config read: SAPI ${PHP_AUDIT_SAPI} (the one serving Nextcloud)"
    fi

    # Check that the version is supported by Nextcloud
    subheader "PHP compatibility"
    local php_major
    php_major=$(echo "$php_major_minor" | cut -d. -f1)
    local php_minor
    php_minor=$(echo "$php_major_minor" | cut -d. -f2)

    # Numeric compare (major*100+minor): the old "major ≥ 8 AND minor ≥ 1"
    # would have declared PHP 9.0 "too old". Also flag EOL branches: 8.1 is
    # EOL since Dec 2025, 8.2 is security-only until Dec 2026.
    local php_num=$(( ${php_major:-0} * 100 + ${php_minor:-0} ))
    if [ "$php_num" -lt 801 ]; then
        crit "PHP ${php_major_minor} — version too old"
        recommend "Upgrade to PHP 8.3+ (check your Nextcloud version's PHP compatibility matrix)"
    elif [ "$php_num" -lt 803 ]; then
        warn "PHP ${php_major_minor} — EOL (8.1: Dec 2025) or security-fixes only (8.2: until Dec 2026)"
        recommend "Plan the upgrade to PHP 8.3/8.4 (check your Nextcloud version's PHP compatibility matrix)"
    else
        ok "PHP ${php_major_minor} supported and maintained"
    fi

    # --- Key PHP settings ---
    subheader "PHP settings"

    local memory_limit upload_max post_max max_exec opcache_enable opcache_mem opcache_strings opcache_max_files opcache_revalidate
    memory_limit=$(get_effective_ini "memory_limit" "128M")
    upload_max=$(get_effective_ini "upload_max_filesize" "2M")
    post_max=$(get_effective_ini "post_max_size" "8M")
    max_exec=$(get_effective_ini "max_execution_time" "30")
    opcache_enable=$(get_effective_ini "opcache.enable" "0")
    opcache_mem=$(get_effective_ini "opcache.memory_consumption" "128")
    opcache_strings=$(get_effective_ini "opcache.interned_strings_buffer" "8")
    opcache_max_files=$(get_effective_ini "opcache.max_accelerated_files" "10000")
    opcache_revalidate=$(get_effective_ini "opcache.revalidate_freq" "2")
    local opcache_jit
    opcache_jit=$(get_effective_ini "opcache.jit" "off")
    local opcache_jit_buffer
    opcache_jit_buffer=$(get_effective_ini "opcache.jit_buffer_size" "0")

    # memory_limit
    local mem_mb
    mem_mb=$(php_to_mb "$memory_limit")
    info "memory_limit = ${memory_limit}"
    if [ "$mem_mb" = "-1" ]; then
        info "memory_limit = -1 (unlimited)"
        recommend "Prefer an explicit value (e.g. 1G): unlimited lets one runaway request exhaust the RAM"
    elif [ "$mem_mb" -lt 512 ]; then
        crit "memory_limit too low (${memory_limit})"
        recommend "Set memory_limit to 512M minimum (1G recommended if RAM > 4 GB)"
    elif [ "$mem_mb" -lt 1024 ] && [ "${SERVER_RAM_MB:-0}" -ge 4096 ]; then
        warn "memory_limit could be increased (${memory_limit})"
        recommend "Set to 1G given the available RAM"
    else
        ok "memory_limit = ${memory_limit}"
    fi

    # upload_max_filesize
    local upload_mb
    upload_mb=$(php_to_mb "$upload_max")
    info "upload_max_filesize = ${upload_max}"
    if [ "$upload_mb" -lt 512 ]; then
        warn "upload_max_filesize low (${upload_max})"
        recommend "Set to 512M or more to allow uploading large files"
    else
        ok "upload_max_filesize = ${upload_max}"
    fi

    # post_max_size
    local post_mb
    post_mb=$(php_to_mb "$post_max")
    if [ "$post_mb" -lt "$upload_mb" ]; then
        crit "post_max_size (${post_max}) < upload_max_filesize (${upload_max})"
        recommend "post_max_size must be >= upload_max_filesize"
    else
        ok "post_max_size = ${post_max}"
    fi

    # max_execution_time
    info "max_execution_time = ${max_exec}s"
    if [ "$max_exec" -lt 300 ] && [ "$max_exec" -ne 0 ]; then
        warn "max_execution_time short (${max_exec}s)"
        recommend "Set to 300 or 3600 for long operations (migration, file scan)"
    else
        ok "max_execution_time = ${max_exec}s"
    fi

    # OPcache
    subheader "OPcache"
    if [[ "$opcache_enable" =~ ^(1|[Oo]n|[Tt]rue)$ ]]; then
        ok "OPcache enabled"
    else
        crit "OPcache disabled (SAPI ${PHP_AUDIT_SAPI})"
        recommend "Enable opcache.enable = 1, essential for Nextcloud performance"
    fi

    info "opcache.memory_consumption = ${opcache_mem} MB"
    if [ "$opcache_mem" -lt 128 ]; then
        warn "opcache.memory_consumption low (${opcache_mem} MB)"
        recommend "Set to 128 MB minimum"
    else
        ok "opcache.memory_consumption = ${opcache_mem} MB"
    fi

    info "opcache.interned_strings_buffer = ${opcache_strings} MB"
    if [ "$opcache_strings" -lt 16 ]; then
        warn "opcache.interned_strings_buffer low (${opcache_strings} MB)"
        recommend "Set to 16 MB minimum"
    fi

    info "opcache.max_accelerated_files = ${opcache_max_files}"
    if [ "$opcache_max_files" -lt 10000 ]; then
        warn "opcache.max_accelerated_files too low (${opcache_max_files})"
        recommend "Set to 10000 minimum (Nextcloud has thousands of PHP files)"
    else
        ok "opcache.max_accelerated_files = ${opcache_max_files}"
    fi

    info "opcache.revalidate_freq = ${opcache_revalidate}"
    if [ "$opcache_revalidate" -gt 60 ]; then
        info "opcache.revalidate_freq high — watch out for frequent code updates"
    fi

    # JIT: Nextcloud is I/O-bound → negligible gain, and the JIT has a history
    # of segfaults with some extensions. Off is a perfectly fine default.
    if [ -n "$opcache_jit_buffer" ] && [ "$opcache_jit_buffer" != "0" ] \
       && [[ ! "$opcache_jit" =~ ^([Oo]ff|[Dd]isable|0)$ ]]; then
        info "opcache.jit = ${opcache_jit} (buffer ${opcache_jit_buffer}) — little benefit for Nextcloud; disable it first if PHP segfaults appear"
    fi

    # OPcache runtime (hit-rate, real memory): NOT readable from here — a
    # standalone `php -r` gets its OWN empty shared-memory segment, so any
    # figure it prints is meaningless for the FPM/Apache workers. Point at
    # the real sources instead of printing misleading numbers.
    info "OPcache runtime stats not readable from the CLI — see Nextcloud → Administration → System, or cachetool against the FPM socket"

    # --- PHP-FPM ---
    subheader "PHP-FPM"
    if ! systemctl list-units --type=service 2>/dev/null | grep -q "php.*fpm"; then
        info "PHP-FPM not detected (mod_php may be used instead)"
    elif [ "$PHP_AUDIT_SAPI" != "fpm" ]; then
        # A php-fpm service can be installed/active without actually serving THIS
        # Nextcloud instance (mod_php handles it instead, cf. detect_php_target).
        # Showing its pool tuning here would look like Nextcloud-relevant advice
        # when it's not — so report the mismatch instead of the pool's settings.
        warn "php-fpm is active but this instance is served by ${PHP_AUDIT_SAPI} instead — the pool(s) below are unrelated to this Nextcloud"
        recommend "If this fpm pool serves nothing, disable it to free RAM (systemctl disable --now phpX.Y-fpm); if it serves another site on this host, audit that site separately"
    else
        # Per-worker memory: measured on the RUNNING workers when possible
        # (fallback 50 MB) — this figure drives pm.max_children and the 8b
        # budget reconciliation, so a real number beats a guess.
        local php_process_mb
        php_process_mb=$(fpm_avg_worker_mb)
        if [ "$php_process_mb" -ne 50 ]; then
            info "Memory per PHP-FPM worker: ~${php_process_mb} MB (measured average of running workers)"
        else
            info "Memory per PHP-FPM worker: ~50 MB (default estimate — not enough running workers to measure)"
        fi
        export FPM_PROC_MB="$php_process_mb"

        local fpm_conf
        local -a all_pools=()
        while IFS= read -r f; do all_pools+=("$f"); done \
            < <(find /etc/php/ -name "*.conf" -path "*/pool.d/*" 2>/dev/null | sort)

        # Narrow to the PHP version already confirmed (detect_php_target) to be
        # the one actually serving this instance. Searching across ALL installed
        # PHP versions and only tie-breaking by owner picks up leftover pools
        # from old PHP upgrades (e.g. php8.2 kept alongside php8.3) and can pick
        # the WRONG one just because it sorts first — even though we already
        # know which version is live.
        local -a version_pools=()
        while IFS= read -r f; do version_pools+=("$f"); done \
            < <(find "/etc/php/${PHP_AUDIT_VER}/fpm/pool.d/" -name "*.conf" 2>/dev/null | sort)
        local -a candidates=()
        if [ "${#version_pools[@]}" -gt 0 ]; then
            candidates=("${version_pools[@]}")
        else
            candidates=("${all_pools[@]}")
        fi

        if [ "${#candidates[@]}" -le 1 ]; then
            fpm_conf="${candidates[0]:-}"
        else
            # Still several pools under the live PHP version (multi-instance
            # server): don't just grab www.conf or the first one alphabetically
            # — that silently reads the WRONG pool's memory_limit/pm.max_children
            # on a shared host. Match by the pool's `user` directive against the
            # Nextcloud webroot owner.
            local -a matches=()
            if [ -n "${NC_DETECTED_USER:-}" ]; then
                local p pool_user
                for p in "${candidates[@]}"; do
                    pool_user=$(get_ini_value "$p" "user" "")
                    [ "$pool_user" = "$NC_DETECTED_USER" ] && matches+=("$p")
                done
            fi
            if [ "${#matches[@]}" -eq 1 ]; then
                fpm_conf="${matches[0]}"
                info "Multiple FPM pools found (${#all_pools[@]}) — matched to this instance by PHP ${PHP_AUDIT_VER} + owner (user=${NC_DETECTED_USER}): $(basename "$fpm_conf")"
            else
                fpm_conf="${candidates[0]}"
                warn "Multiple FPM pools found (${#all_pools[@]}, ${#candidates[@]} under PHP ${PHP_AUDIT_VER}) — could not confidently match one to this Nextcloud instance (owner ${NC_DETECTED_USER:-unknown} matched ${#matches[@]}), showing ${fpm_conf}"
                recommend "Verify manually which pool serves this vhost (check its 'listen' socket against the web server config): ${candidates[*]}"
            fi
        fi

        if [ -z "$fpm_conf" ] || [ ! -f "$fpm_conf" ]; then
            warn "PHP-FPM configuration not found"
        else
            info "Pool config: ${fpm_conf}"

            local pm pm_max pm_start pm_min_spare pm_max_spare pm_max_requests
            pm=$(get_ini_value "$fpm_conf" "pm" "dynamic")
            pm_max=$(get_ini_value "$fpm_conf" "pm.max_children" "5")
            pm_start=$(get_ini_value "$fpm_conf" "pm.start_servers" "2")
            pm_min_spare=$(get_ini_value "$fpm_conf" "pm.min_spare_servers" "1")
            pm_max_spare=$(get_ini_value "$fpm_conf" "pm.max_spare_servers" "3")
            pm_max_requests=$(get_ini_value "$fpm_conf" "pm.max_requests" "0")

            info "pm = ${pm}"
            info "pm.max_children = ${pm_max}"
            info "pm.start_servers = ${pm_start}"
            info "pm.min_spare_servers = ${pm_min_spare}"
            info "pm.max_spare_servers = ${pm_max_spare}"
            info "pm.max_requests = ${pm_max_requests}"

            # Compute the recommended max_children
            # Formula: (RAM available for PHP) / (memory per process — measured
            # above when workers are running, else ~50 MB)
            local ram_for_php=$(( ${SERVER_RAM_BUDGET_MB:-2048} * ${PHP_BUDGET_SHARE_PCT:-60} / 100 ))  # PHP share of the RAM budget
            local recommended_max_children=$(( ram_for_php / php_process_mb ))

            # Clamp to reasonable values
            if [ "$recommended_max_children" -gt 512 ]; then
                recommended_max_children=512
            fi
            if [ "$recommended_max_children" -lt 5 ]; then
                recommended_max_children=5
            fi

            if [ "$pm" = "dynamic" ]; then
                ok "Dynamic mode (suitable for most cases)"
            elif [ "$pm" = "static" ]; then
                info "Static mode — fits dedicated Nextcloud servers"
            elif [ "$pm" = "ondemand" ]; then
                warn "Ondemand mode — may add latency"
                recommend "Switch to dynamic for better performance"
            fi

            # pm.max_children is a concurrency LIMIT (max number of PHP processes),
            # not a target: recommended_max_children = CEILING that the RAM budget
            # can hold (~${php_process_mb} MB/process). Above it → OOM risk under
            # load; below it → headroom (only raise if saturation).
            if [ "$pm_max" -gt "$recommended_max_children" ]; then
                warn "pm.max_children (${pm_max}) exceeds RAM capacity (~${recommended_max_children} max, ~${php_process_mb} MB/process)"
                recommend "Reduce to ~${recommended_max_children} to avoid OOM under peak load"
            else
                ok "pm.max_children = ${pm_max} (under the RAM ceiling ~${recommended_max_children} ; concurrency limit, raise if saturation)"
            fi

            if [ "$pm_max_requests" = "0" ]; then
                warn "pm.max_requests = 0 (unlimited) — memory-leak risk"
                recommend "Set pm.max_requests = 500 to recycle processes"
            else
                ok "pm.max_requests = ${pm_max_requests}"
            fi
        fi

        # --- Multi-pool memory accounting (all instances on this host) ---
        # Several Nextcloud instances usually run one FPM pool each. The PHP
        # memory that matters is the SUM over the DISTINCT instances of
        # pm.max_children × ~per-process MB. We dedupe by pool name (basename):
        # the SAME pool defined under two PHP versions (a classic leftover after
        # a PHP upgrade) must not be double-counted — only the obsolete version's
        # php-fpm wastes some idle RAM, but the instance can only saturate once.
        local fpm_pool_count=0 pool_f pc pool_name
        local -A pool_children=()   # name -> max pm.max_children seen across versions
        while IFS= read -r pool_f; do
            [ -f "$pool_f" ] || continue
            pc=$(get_ini_value "$pool_f" "pm.max_children" "0")
            [[ "$pc" =~ ^[0-9]+$ ]] || pc=0
            pool_name=$(basename "$pool_f" .conf)
            [ "${pool_children[$pool_name]:-0}" -lt "$pc" ] && pool_children[$pool_name]="$pc"
            fpm_pool_count=$(( fpm_pool_count + 1 ))
        done < <(find /etc/php/ -path "*/pool.d/*.conf" 2>/dev/null | sort)

        local fpm_total_children=0 fpm_uniq_count=0
        for pool_name in "${!pool_children[@]}"; do
            fpm_total_children=$(( fpm_total_children + pool_children[$pool_name] ))
            fpm_uniq_count=$(( fpm_uniq_count + 1 ))
        done
        export FPM_TOTAL_MB=$(( fpm_total_children * php_process_mb ))

        if [ "$fpm_uniq_count" -gt 1 ]; then
            info "PHP-FPM: ${fpm_pool_count} pool file(s), ${fpm_uniq_count} distinct instance(s) (Σ pm.max_children = ${fpm_total_children} → ~${FPM_TOTAL_MB} MB at ~${php_process_mb} MB/process)"
            [ -z "${NC_INSTANCES:-}" ] && recommend "Multi-instance host: size each pool's pm.max_children by instance weight, or pass NC_INSTANCES=\"pool:weight,...\" for a prescriptive split. The SUM must fit the PHP share of the RAM budget."
        fi

        # Same pool name under several PHP versions = leftover after a PHP upgrade.
        if [ "$fpm_pool_count" -gt "$fpm_uniq_count" ]; then
            warn "${fpm_pool_count} pool files for only ${fpm_uniq_count} instances — duplicate pools across PHP versions (leftover from a PHP upgrade)"
            recommend "Each running php-fpm version still spawns its pools (idle RAM wasted). Remove the obsolete version's pools (/etc/php/<old>/fpm/pool.d/) and disable that php-fpm if unused."
        fi

        # --- NC_INSTANCES: prescriptive per-pool max_children split ---
        # When the operator declares instance weights, NC_RAM_BUDGET_PCT is read
        # as the TOTAL stack budget (all instances), and the PHP share is split
        # across pools by weight → a target pm.max_children per pool.
        if [ -n "${NC_INSTANCES:-}" ]; then
            subheader "PHP-FPM pools — budget split (NC_INSTANCES)"
            info "NC_RAM_BUDGET_PCT read as the TOTAL stack budget here (all instances)."
            local php_share=$(( ${SERVER_RAM_BUDGET_MB:-2048} * ${PHP_BUDGET_SHARE_PCT:-60} / 100 ))
            local _specs spec name w total_w=0
            IFS=',' read -ra _specs <<< "$NC_INSTANCES"
            for spec in "${_specs[@]}"; do
                w="${spec##*:}"; [[ "$w" =~ ^[0-9]+$ ]] || w=0
                total_w=$(( total_w + w ))
            done
            if [ "$total_w" -le 0 ]; then
                warn "NC_INSTANCES has no valid weights (expected \"name:weight,name:weight,...\")"
            else
                info "PHP share of budget: ${php_share} MB ; total weight: ${total_w}"
                local sum_target=0 target curr pool_path
                for spec in "${_specs[@]}"; do
                    name="${spec%%:*}"; w="${spec##*:}"
                    { [[ "$w" =~ ^[0-9]+$ ]] && [ "$w" -gt 0 ]; } || { warn "Invalid NC_INSTANCES item '${spec}' (expected name:weight)"; continue; }
                    target=$(( php_share * w / total_w / php_process_mb ))
                    [ "$target" -lt 1 ] && target=1
                    sum_target=$(( sum_target + target ))
                    pool_path=$(find /etc/php/ -path "*/pool.d/${name}.conf" 2>/dev/null | head -1)
                    if [ -n "$pool_path" ]; then
                        curr=$(get_ini_value "$pool_path" "pm.max_children" "?")
                        info "${name} (weight ${w}/${total_w}) → target pm.max_children ~${target} (current ${curr})"
                    else
                        warn "${name}: no pool file /etc/php/*/fpm/pool.d/${name}.conf found"
                    fi
                done
                info "Σ target children = ${sum_target} (~$(( sum_target * php_process_mb )) MB, within the ${php_share} MB PHP share)"
                recommend "Set each pool's pm.max_children to its target; keep the sum within the PHP budget."
            fi
        fi
    fi

    # Check the required PHP modules. bcmath/gmp are RECOMMENDED, not required
    # (only some features need them, e.g. WebAuthn passwordless) — flagging
    # them CRIT screamed "critical" on perfectly healthy servers.
    subheader "Required PHP modules"
    local required_modules=("curl" "gd" "mbstring" "xml" "zip" "intl" "bcmath" "gmp" "imagick" "apcu" "redis")
    for mod in "${required_modules[@]}"; do
        if php -m 2>/dev/null | grep -qi "^${mod}$"; then
            ok "Module ${mod} present"
        else
            case "$mod" in
                apcu|redis|imagick|bcmath|gmp)
                    warn "Module ${mod} missing (recommended)"
                    recommend "Install php${php_major_minor}-${mod} or php-${mod}"
                    ;;
                *)
                    crit "Module ${mod} missing (required)"
                    recommend "Install php${php_major_minor}-${mod}"
                    ;;
            esac
        fi
    done
}

# ==============================================================================
# SECTION 4 : Apache
# ==============================================================================

audit_apache() {
    header "4. APACHE"

    if ! command -v apache2 &>/dev/null && ! command -v httpd &>/dev/null; then
        info "Apache not installed — section skipped"
        return
    fi

    if ! systemctl is-active --quiet apache2 2>/dev/null; then
        info "Apache not active — section skipped"
        return
    fi

    local apache_version
    apache_version=$(apache2 -v 2>/dev/null | head -1)
    info "${apache_version}"

    # MPM
    subheader "MPM (Multi-Processing Module)"
    local mpm
    # grep -oE 'mpm_(prefork|worker|event)' : without it we'd capture "mpm_prefork_module"
    # (the _module suffix) and the comparison below fails → prefork not flagged.
    mpm=$(apache2ctl -M 2>/dev/null | grep -oE 'mpm_(prefork|worker|event)' | head -1 || echo "unknown")
    info "Active MPM: ${mpm}"

    if [ "$mpm" = "mpm_event" ]; then
        ok "MPM event (recommended for performance)"
    elif [ "$mpm" = "mpm_worker" ]; then
        ok "MPM worker (acceptable)"
    elif [ "$mpm" = "mpm_prefork" ]; then
        warn "MPM prefork — less performant"
        recommend "Switch to mpm_event if possible (requires php-fpm instead of mod_php)"
    fi

    # MaxRequestWorkers vs server capacity — read from the ACTIVE MPM's config
    # only: a blind recursive grep over /etc/apache2/ also hits the OTHER MPMs'
    # files in mods-available/ and could pick the inactive one's value.
    local max_workers mw_note=""
    max_workers=$(grep -hE "^\s*MaxRequestWorkers" \
        "/etc/apache2/mods-enabled/${mpm}.conf" 2>/dev/null | tail -1 | awk '{print $2}')
    if [ -z "$max_workers" ]; then
        max_workers=$(grep -rhE "^\s*MaxRequestWorkers" \
            /etc/apache2/conf-enabled/ /etc/apache2/apache2.conf 2>/dev/null | tail -1 | awk '{print $2}')
    fi
    if [ -z "$max_workers" ] && [ "$mpm" != "unknown" ]; then
        # No explicit directive → Apache's compiled-in defaults apply.
        if [ "$mpm" = "mpm_prefork" ]; then max_workers=256; else max_workers=400; fi
        mw_note=" (Apache default — no explicit directive found)"
    fi
    if [ -n "$max_workers" ] && [[ "$max_workers" =~ ^[0-9]+$ ]]; then
        info "MaxRequestWorkers = ${max_workers}${mw_note}"
        # Per-worker memory depends on the model, NOT a flat figure:
        #  - mpm_prefork + mod_php: each worker is a full Apache process WITH PHP
        #    embedded → heavy (~40-80 MB). MaxRequestWorkers × that IS the real
        #    PHP memory, so flag it against the budget.
        #  - mpm_event/worker + PHP-FPM: each worker is a light proxy thread
        #    (~a few MB); the real PHP memory lives in the FPM pools (accounted
        #    separately). A high MaxRequestWorkers is fine here, so we must not
        #    bill it the PHP memory again (that was a false positive + double count).
        local estimated_worker_mb=40
        if { [ "$mpm" = "mpm_event" ] || [ "$mpm" = "mpm_worker" ]; } && [ "${PHP_AUDIT_SAPI:-}" = "fpm" ]; then
            estimated_worker_mb=8
        fi
        local max_worker_ram_mb=$(( max_workers * estimated_worker_mb ))
        export APACHE_MEM_MB="$max_worker_ram_mb"
        if [ "$estimated_worker_mb" -le 8 ]; then
            ok "MaxRequestWorkers = ${max_workers} (light proxy threads, ~${max_worker_ram_mb} MB; real PHP memory is in the FPM pools)"
        elif [ "$max_worker_ram_mb" -gt "${SERVER_RAM_BUDGET_MB:-2048}" ]; then
            warn "MaxRequestWorkers (${max_workers}) could saturate RAM under peak load (~${max_worker_ram_mb} MB, mod_php embeds PHP per process)"
            recommend "Reduce MaxRequestWorkers, or move to mpm_event + PHP-FPM, vs the ${SERVER_RAM_BUDGET_MB:-?} MB budget"
        else
            ok "MaxRequestWorkers (${max_workers}) consistent with the RAM budget (~${max_worker_ram_mb} MB)"
        fi
    fi

    # Important modules
    subheader "Apache modules"
    local important_modules=("rewrite" "headers" "env" "dir" "mime" "ssl")
    for mod in "${important_modules[@]}"; do
        if apache2ctl -M 2>/dev/null | grep -qi "${mod}_module"; then
            ok "Module ${mod} enabled"
        else
            warn "Module ${mod} not enabled"
            recommend "Enable with: a2enmod ${mod}"
        fi
    done

    # HTTP/2 — mod_http2 refuses to run under mpm_prefork, so don't recommend
    # the impossible there: the real fix is moving off prefork first (cf. MPM).
    if apache2ctl -M 2>/dev/null | grep -qi "http2_module"; then
        if [ "$mpm" = "mpm_prefork" ]; then
            info "mod_http2 loaded but inoperative under mpm_prefork"
        else
            ok "HTTP/2 available"
        fi
    elif [ "$mpm" = "mpm_prefork" ]; then
        info "HTTP/2 unavailable under mpm_prefork — switch to mpm_event + PHP-FPM first (cf. MPM recommendation above)"
    else
        warn "HTTP/2 not enabled"
        recommend "a2enmod http2 and add 'Protocols h2 http/1.1' in the SSL vhosts"
    fi

    # Security headers
    subheader "Apache security"
    local apache_conf="/etc/apache2/conf-available/security.conf"
    if [ -f "$apache_conf" ]; then
        local server_tokens
        server_tokens=$(grep -E "^ServerTokens" "$apache_conf" 2>/dev/null | awk '{print $2}')
        if [ "$server_tokens" = "Prod" ] || [ "$server_tokens" = "ProductOnly" ]; then
            ok "ServerTokens = ${server_tokens} (hidden)"
        else
            warn "ServerTokens = ${server_tokens:-not set}"
            recommend "Set ServerTokens Prod to hide the version"
        fi

        local server_sig
        server_sig=$(grep -E "^ServerSignature" "$apache_conf" 2>/dev/null | awk '{print $2}')
        if [ "$server_sig" = "Off" ]; then
            ok "ServerSignature Off"
        else
            warn "ServerSignature not disabled"
            recommend "Set ServerSignature Off"
        fi
    fi

    # TLS protocols (parity with the Nginx section): Apache's stock default
    # ("SSLProtocol all -SSLv3") still allows TLSv1.0/1.1.
    if apache2ctl -M 2>/dev/null | grep -qi "ssl_module"; then
        local ssl_proto tls10="no" tls11="no"
        ssl_proto=$(grep -rhE "^\s*SSLProtocol\b" /etc/apache2/mods-enabled/ /etc/apache2/sites-enabled/ /etc/apache2/conf-enabled/ 2>/dev/null \
                    | tail -1 | sed -E 's/^\s*SSLProtocol\s*//')
        if [ -z "$ssl_proto" ]; then
            warn "SSLProtocol not set — Apache's default still allows TLSv1.0/1.1"
            recommend "Set: SSLProtocol -all +TLSv1.2 +TLSv1.3 (e.g. in mods-enabled/ssl.conf)"
        else
            info "SSLProtocol: ${ssl_proto}"
            # "all" form: old TLS enabled unless explicitly removed (-TLSv1…);
            # additive form: enabled only if explicitly listed (+TLSv1…).
            if echo "$ssl_proto" | grep -qiE '(^|[[:space:]])\+?all([[:space:]]|$)'; then
                echo "$ssl_proto" | grep -qiE '(^|[[:space:]])-TLSv1([[:space:]]|$)'    || tls10="yes"
                echo "$ssl_proto" | grep -qiE '(^|[[:space:]])-TLSv1\.1([[:space:]]|$)' || tls11="yes"
            else
                echo "$ssl_proto" | grep -qiE '(^|[[:space:]])\+?TLSv1([[:space:]]|$)'    && tls10="yes"
                echo "$ssl_proto" | grep -qiE '(^|[[:space:]])\+?TLSv1\.1([[:space:]]|$)' && tls11="yes"
            fi
            if [ "$tls10" = "yes" ]; then
                crit "TLSv1.0 still enabled (Apache) — vulnerable"
                recommend "SSLProtocol -all +TLSv1.2 +TLSv1.3"
            elif [ "$tls11" = "yes" ]; then
                warn "TLSv1.1 still enabled (Apache) — obsolete"
                recommend "SSLProtocol -all +TLSv1.2 +TLSv1.3"
            else
                ok "Modern TLS protocols (Apache)"
            fi
        fi
    fi

    # Check compression
    if apache2ctl -M 2>/dev/null | grep -qi "deflate_module"; then
        ok "Compression (mod_deflate) enabled"
    else
        warn "Compression not enabled"
        recommend "a2enmod deflate to enable gzip compression"
    fi

    # KeepAlive
    local keepalive
    keepalive=$(grep -rE "^KeepAlive " /etc/apache2/ 2>/dev/null | head -1 | awk '{print $2}')
    if [ "$keepalive" = "On" ] || [ -z "$keepalive" ]; then
        ok "KeepAlive enabled"
    else
        warn "KeepAlive disabled"
        recommend "Enable KeepAlive On for better performance"
    fi
}

# ==============================================================================
# SECTION 5 : Nginx
# ==============================================================================

audit_nginx() {
    header "5. NGINX"

    if ! command -v nginx &>/dev/null; then
        info "Nginx not installed — section skipped"
        return
    fi

    if ! systemctl is-active --quiet nginx 2>/dev/null; then
        info "Nginx not active — section skipped"
        return
    fi

    local nginx_version
    nginx_version=$(nginx -v 2>&1)
    info "${nginx_version}"

    local nginx_conf="/etc/nginx/nginx.conf"

    # Worker processes
    subheader "Workers"
    local workers
    workers=$(grep -E "^\s*worker_processes" "$nginx_conf" 2>/dev/null | awk '{print $2}' | tr -d ';')
    info "worker_processes = ${workers:-not set}"

    if [ "$workers" = "auto" ]; then
        ok "worker_processes auto (matches the number of cores)"
    elif [ -n "$workers" ]; then
        if [ "$workers" -lt "${SERVER_CPU_CORES:-1}" ] 2>/dev/null; then
            warn "worker_processes (${workers}) < number of cores (${SERVER_CPU_CORES:-?})"
            recommend "Use worker_processes auto"
        else
            ok "worker_processes = ${workers}"
        fi
    fi

    # Worker connections
    local worker_conn
    worker_conn=$(grep -E "^\s*worker_connections" "$nginx_conf" 2>/dev/null | awk '{print $2}' | tr -d ';')
    info "worker_connections = ${worker_conn:-not set}"
    if [ -n "$worker_conn" ] && [ "$worker_conn" -lt 1024 ]; then
        warn "worker_connections low (${worker_conn})"
        recommend "Increase to 1024 minimum"
    fi

    # Gzip
    subheader "Compression"
    if grep -qE "^\s*gzip\s+on" "$nginx_conf" 2>/dev/null; then
        ok "Gzip enabled"
    else
        warn "Gzip not enabled in nginx.conf"
        recommend "Enable gzip on and configure gzip_types"
    fi

    # Security
    subheader "Nginx security"
    if grep -rqE "server_tokens\s+off" /etc/nginx/ 2>/dev/null; then
        ok "server_tokens off (version hidden)"
    else
        warn "server_tokens not disabled"
        recommend "Add server_tokens off in the http block"
    fi

    # SSL
    subheader "SSL/TLS"
    if grep -rqE "ssl_protocols" /etc/nginx/ 2>/dev/null; then
        local ssl_protocols
        ssl_protocols=$(grep -rE "ssl_protocols" /etc/nginx/ 2>/dev/null | head -1 | awk '{$1=""; print $0}' | tr -d ';')
        info "ssl_protocols:${ssl_protocols}"
        if echo "$ssl_protocols" | grep -q "TLSv1 " || echo "$ssl_protocols" | grep -q "TLSv1.0"; then
            crit "TLSv1.0 still enabled — vulnerable"
            recommend "Use only TLSv1.2 TLSv1.3"
        elif echo "$ssl_protocols" | grep -q "TLSv1.1"; then
            warn "TLSv1.1 still enabled — obsolete"
            recommend "Use only TLSv1.2 TLSv1.3"
        else
            ok "Modern SSL protocols"
        fi
    fi

    # HTTP/2: "listen ... http2" (old) or "http2 on;" (nginx >= 1.25).
    # We do NOT settle for a listen 443 ssl (that doesn't prove HTTP/2).
    if grep -rqE "listen[^;]*http2|^[[:space:]]*http2[[:space:]]+on" /etc/nginx/ 2>/dev/null; then
        ok "HTTP/2 enabled"
    else
        warn "HTTP/2 not detected"
        recommend "Enable HTTP/2 (the 'http2 on;' directive on TLS vhosts)"
    fi

    # Buffers and timeouts
    subheader "Buffers and limits"
    local client_max
    client_max=$(grep -rE "client_max_body_size" /etc/nginx/ 2>/dev/null | head -1 | awk '{print $2}' | tr -d ';')
    if [ -n "$client_max" ]; then
        info "client_max_body_size = ${client_max}"
        local max_body_mb
        max_body_mb=$(php_to_mb "$client_max")
        if [ "$max_body_mb" -lt 512 ]; then
            warn "client_max_body_size low (${client_max})"
            recommend "Increase to 512M or more for Nextcloud"
        else
            ok "client_max_body_size = ${client_max}"
        fi
    else
        warn "client_max_body_size not set (default: 1M)"
        recommend "Set client_max_body_size 512M minimum"
    fi
}

# ==============================================================================
# SECTION 6 : PostgreSQL
# ==============================================================================

audit_postgresql() {
    header "6. POSTGRESQL"

    if ! systemctl is-active --quiet postgresql 2>/dev/null; then
        info "PostgreSQL not active — section skipped"
        return
    fi

    # Guard: don't emit false recommendations if we can't read the config.
    if ! sudo -u postgres psql -tAc "SELECT 1" >/dev/null 2>&1; then
        warn "PostgreSQL active but querying impossible (permissions/socket) — recommendations skipped"
        return
    fi

    local pg_version
    pg_version=$(sudo -u postgres psql -t -c "SELECT version();" 2>/dev/null | head -1 | xargs)
    info "Version: ${pg_version}"
    if [ -n "${NC_DBTYPE:-}" ] && [ "$NC_DBTYPE" != "pgsql" ]; then
        info "PostgreSQL is running but this Nextcloud instance uses \"${NC_DBTYPE}\" — recos below for reference only (another host usage?)"
    fi

    subheader "Memory settings"
    local shared_buffers effective_cache work_mem maint_work_mem max_conn
    shared_buffers=$(get_pg_setting "shared_buffers" "128MB")
    effective_cache=$(get_pg_setting "effective_cache_size" "4GB")
    work_mem=$(get_pg_setting "work_mem" "4MB")
    maint_work_mem=$(get_pg_setting "maintenance_work_mem" "64MB")
    max_conn=$(get_pg_setting "max_connections" "100")

    local shared_mb effective_mb work_mb maint_mb
    shared_mb=$(pg_to_mb "$shared_buffers")
    effective_mb=$(pg_to_mb "$effective_cache")
    work_mb=$(pg_to_mb "$work_mem")
    maint_mb=$(pg_to_mb "$maint_work_mem")
    export DB_PG_MB="$shared_mb"   # shared_buffers (real alloc) for the budget reconciliation

    info "shared_buffers = ${shared_buffers}"
    info "effective_cache_size = ${effective_cache}"
    info "work_mem = ${work_mem}"
    info "maintenance_work_mem = ${maint_work_mem}"
    info "max_connections = ${max_conn}"

    # RAM-based recommendations. shared_buffers uses the DB's share of the
    # STACK budget (not "25% of RAM" as for a dedicated DB server — PHP-FPM
    # already claims its own share of the same budget, cf. DB_BUDGET_SHARE_PCT
    # / NC_DB_SHARE_PCT, section 8b). effective_cache_size stays against the
    # full budget: it's a planner hint about total cache (OS + PG), not a
    # real allocation, so it isn't part of the over-subscription problem.
    local recommended_shared recommended_effective recommended_work recommended_maint
    recommended_shared=$(( ${SERVER_RAM_BUDGET_MB:-2048} * ${DB_BUDGET_SHARE_PCT:-28} / 100 ))
    recommended_effective=$(( ${SERVER_RAM_BUDGET_MB:-2048} * 3 / 4 ))
    recommended_work=$(( ${SERVER_RAM_BUDGET_MB:-2048} / ${max_conn:-100} ))
    recommended_maint=$(( ${SERVER_RAM_BUDGET_MB:-2048} / 16 ))

    # Reasonable limits
    [ "$recommended_shared" -gt 8192 ] && recommended_shared=8192
    [ "$recommended_work" -gt 256 ] && recommended_work=256
    [ "$recommended_work" -lt 4 ] && recommended_work=4
    [ "$recommended_maint" -gt 2048 ] && recommended_maint=2048
    [ "$recommended_maint" -lt 64 ] && recommended_maint=64

    # shared_buffers: DB's share of the stack budget (see comment above)
    if [ "$shared_mb" -lt "$((recommended_shared / 2))" ]; then
        warn "shared_buffers too low (${shared_buffers})"
        recommend "Set to ~${recommended_shared}MB (~${DB_BUDGET_SHARE_PCT:-28}% of the stack budget, shared with PHP)"
    elif [ "$shared_mb" -gt "$((recommended_shared * 2))" ]; then
        warn "shared_buffers very high (${shared_buffers})"
        recommend "Reduce to ~${recommended_shared}MB (~${DB_BUDGET_SHARE_PCT:-28}% of the stack budget, shared with PHP)"
    else
        ok "shared_buffers = ${shared_buffers} (recommended ~${recommended_shared}MB)"
    fi

    # effective_cache_size: ~75% of RAM
    if [ "$effective_mb" -lt "$((recommended_effective / 2))" ]; then
        warn "effective_cache_size low (${effective_cache})"
        recommend "Set to ~${recommended_effective}MB (~75% of RAM)"
    else
        ok "effective_cache_size = ${effective_cache}"
    fi

    # work_mem
    if [ "$work_mb" -lt 4 ]; then
        warn "work_mem very low (${work_mem})"
        recommend "Set to ${recommended_work}MB"
    else
        ok "work_mem = ${work_mem} (recommended ~${recommended_work}MB)"
    fi

    # maintenance_work_mem
    if [ "$maint_mb" -lt 64 ]; then
        warn "maintenance_work_mem low (${maint_work_mem})"
        recommend "Set to ${recommended_maint}MB"
    else
        ok "maintenance_work_mem = ${maint_work_mem}"
    fi

    # WAL
    subheader "WAL and checkpoints"
    local wal_buffers checkpoint_completion checkpoint_timeout
    wal_buffers=$(get_pg_setting "wal_buffers" "-1")
    checkpoint_completion=$(get_pg_setting "checkpoint_completion_target" "0.9")
    checkpoint_timeout=$(get_pg_setting "checkpoint_timeout" "5min")

    info "wal_buffers = ${wal_buffers}"
    info "checkpoint_completion_target = ${checkpoint_completion}"
    info "checkpoint_timeout = ${checkpoint_timeout}"

    # Numeric compare — a string compare would wrongly warn on "0.90" vs "0.9".
    if awk "BEGIN{exit !((${checkpoint_completion:-0.9}) < 0.85)}" 2>/dev/null; then
        warn "checkpoint_completion_target = ${checkpoint_completion}"
        recommend "Set to 0.9 to smooth out writes"
    else
        ok "checkpoint_completion_target = ${checkpoint_completion}"
    fi

    # max_connections
    subheader "Connections"
    if [ "$max_conn" -gt 200 ]; then
        warn "max_connections high (${max_conn})"
        recommend "For Nextcloud, 100-150 is usually enough. Reduce if possible."
    else
        ok "max_connections = ${max_conn}"
    fi

    # I/O and storage: adapt to the detected disk type (SSD vs mechanical).
    subheader "I/O and storage"
    local random_page_cost effective_io
    random_page_cost=$(get_pg_setting "random_page_cost" "4")
    effective_io=$(get_pg_setting "effective_io_concurrency" "1")
    info "random_page_cost = ${random_page_cost}"
    info "effective_io_concurrency = ${effective_io}"
    if [ "${SERVER_DISK_SSD:-unknown}" = "true" ]; then
        if awk "BEGIN{exit !(${random_page_cost:-4} > 2)}" 2>/dev/null; then
            warn "random_page_cost = ${random_page_cost} on SSD"
            recommend "On SSD/NVMe, lower random_page_cost to ~1.1"
        else
            ok "random_page_cost = ${random_page_cost} (SSD-appropriate)"
        fi
        if [ "${effective_io:-1}" -lt 100 ] 2>/dev/null; then
            warn "effective_io_concurrency low (${effective_io}) on SSD"
            recommend "On SSD/NVMe, set effective_io_concurrency to ~200"
        else
            ok "effective_io_concurrency = ${effective_io}"
        fi
    elif [ "${SERVER_DISK_SSD:-unknown}" = "false" ]; then
        ok "I/O settings left at default (mechanical disk)"
    else
        info "Disk type undetermined — I/O not evaluated"
    fi

    # Logging
    subheader "Logging"
    local log_min_duration
    log_min_duration=$(get_pg_setting "log_min_duration_statement" "-1")
    if [ "$log_min_duration" = "-1" ]; then
        info "log_min_duration_statement disabled"
        recommend "Enable at 1000 (ms) to detect slow queries"
    else
        ok "log_min_duration_statement = ${log_min_duration}ms"
    fi
}

# ==============================================================================
# SECTION 7 : MariaDB
# ==============================================================================

audit_mariadb() {
    header "7. MARIADB"

    if ! systemctl is-active --quiet mariadb 2>/dev/null && ! systemctl is-active --quiet mysql 2>/dev/null; then
        info "MariaDB/MySQL not active — section skipped"
        return
    fi

    # Guard: without access (root/socket auth), no point emitting false recommendations.
    if ! mysql -N -e "SELECT 1" >/dev/null 2>&1; then
        warn "MariaDB/MySQL active but querying impossible (root/socket auth) — recommendations skipped"
        return
    fi

    local db_version
    db_version=$(mysql -N -e "SELECT VERSION()" 2>/dev/null || echo "unknown")
    info "Server version: ${db_version:-unknown}"
    if [ -n "${NC_DBTYPE:-}" ] && [ "$NC_DBTYPE" != "mysql" ]; then
        info "MySQL/MariaDB is running but this Nextcloud instance uses \"${NC_DBTYPE}\" — recos below for reference only (another host usage?)"
    fi

    subheader "InnoDB"
    local innodb_buffer innodb_log_size innodb_flush innodb_io
    innodb_buffer=$(get_mysql_setting "innodb_buffer_pool_size" "0")
    innodb_log_size=$(get_mysql_setting "innodb_log_file_size" "0")
    innodb_flush=$(get_mysql_setting "innodb_flush_log_at_trx_commit" "1")
    innodb_io=$(get_mysql_setting "innodb_io_capacity" "200")

    local innodb_buffer_mb=$(( innodb_buffer / 1024 / 1024 ))
    local innodb_log_mb=$(( innodb_log_size / 1024 / 1024 ))
    export DB_INNODB_MB="$innodb_buffer_mb"   # for the budget reconciliation

    local innodb_flush_method
    innodb_flush_method=$(get_mysql_setting "innodb_flush_method" "")

    info "innodb_buffer_pool_size = ${innodb_buffer_mb} MB"
    info "innodb_log_file_size = ${innodb_log_mb} MB"
    info "innodb_flush_log_at_trx_commit = ${innodb_flush}"
    info "innodb_io_capacity = ${innodb_io}"
    info "innodb_flush_method = ${innodb_flush_method:-default}"

    # innodb_flush_method: O_DIRECT avoids double caching (OS + InnoDB).
    if [ "$innodb_flush_method" = "O_DIRECT" ]; then
        ok "innodb_flush_method = O_DIRECT (recommended)"
    else
        warn "innodb_flush_method = ${innodb_flush_method:-default}"
        recommend "Set innodb_flush_method = O_DIRECT (avoids OS/InnoDB double caching)"
    fi

    # innodb_io_capacity: adapt to the detected disk type.
    if [ "${SERVER_DISK_SSD:-unknown}" = "true" ]; then
        if [ "${innodb_io:-200}" -lt 1000 ] 2>/dev/null; then
            warn "innodb_io_capacity low (${innodb_io}) on SSD"
            recommend "On SSD/NVMe, increase innodb_io_capacity (~2000) and innodb_io_capacity_max"
        else
            ok "innodb_io_capacity = ${innodb_io} (SSD-appropriate)"
        fi
    fi

    # Recommendations: InnoDB's share of the STACK budget, not "50-70% of RAM"
    # as for a dedicated DB server — PHP-FPM already claims its own share of
    # the same budget (cf. DB_BUDGET_SHARE_PCT / NC_DB_SHARE_PCT, section 8b).
    local recommended_buffer=$(( ${SERVER_RAM_BUDGET_MB:-2048} * ${DB_BUDGET_SHARE_PCT:-28} / 100 ))
    [ "$recommended_buffer" -gt 32768 ] && recommended_buffer=32768

    if [ "$innodb_buffer_mb" -lt "$((recommended_buffer / 3))" ]; then
        crit "innodb_buffer_pool_size very low (${innodb_buffer_mb} MB)"
        recommend "Set to ~${recommended_buffer} MB (~${DB_BUDGET_SHARE_PCT:-28}% of the stack budget, shared with PHP)"
    elif [ "$innodb_buffer_mb" -lt "$((recommended_buffer * 2 / 3))" ]; then
        warn "innodb_buffer_pool_size could be increased (${innodb_buffer_mb} MB)"
        recommend "Set to ~${recommended_buffer} MB (~${DB_BUDGET_SHARE_PCT:-28}% of the stack budget, shared with PHP)"
    else
        ok "innodb_buffer_pool_size = ${innodb_buffer_mb} MB (recommended ~${recommended_buffer} MB)"
    fi

    # innodb_log_file_size: classic target ≈ 25% of the buffer pool (enough redo
    # headroom for write bursts). Scale it with the pool, not a flat 64 MB —
    # e.g. 64 MB of redo for an 8 GB pool is far too small. Floor 256 MB; cap
    # 4 GB (a huge redo slows crash recovery and rarely helps a moderate-write
    # Nextcloud DB).
    local recommended_log=$(( innodb_buffer_mb / 4 ))
    [ "$recommended_log" -lt 256 ] && recommended_log=256
    [ "$recommended_log" -gt 4096 ] && recommended_log=4096
    if [ "$innodb_log_mb" -lt "$(( recommended_log / 2 ))" ]; then
        warn "innodb_log_file_size very low (${innodb_log_mb} MB) for a ${innodb_buffer_mb} MB buffer pool"
        recommend "Set to ~${recommended_log} MB (~25% of the buffer pool) for write-burst headroom"
    elif [ "$innodb_log_mb" -lt "$recommended_log" ]; then
        info "innodb_log_file_size = ${innodb_log_mb} MB (could grow toward ~${recommended_log} MB ≈ 25% of the pool)"
    else
        ok "innodb_log_file_size = ${innodb_log_mb} MB (≥ ~25% of the buffer pool)"
    fi

    # flush
    if [ "$innodb_flush" = "1" ]; then
        ok "innodb_flush_log_at_trx_commit = 1 (safe, full ACID)"
        info "Value 2 possible if performance outweighs 100% durability"
    elif [ "$innodb_flush" = "2" ]; then
        warn "innodb_flush_log_at_trx_commit = 2 (loss risk on OS crash)"
    elif [ "$innodb_flush" = "0" ]; then
        crit "innodb_flush_log_at_trx_commit = 0 (high data-loss risk)"
        recommend "Set to 1 for data safety"
    fi

    # Connections
    subheader "Connections"
    local max_conn
    max_conn=$(get_mysql_setting "max_connections" "151")
    info "max_connections = ${max_conn}"
    if [ "$max_conn" -gt 300 ]; then
        warn "max_connections high (${max_conn})"
        recommend "For Nextcloud, 150-200 is usually enough"
    else
        ok "max_connections = ${max_conn}"
    fi

    # Character set
    subheader "Encoding"
    local charset
    charset=$(get_mysql_setting "character_set_server" "")
    local collation
    collation=$(get_mysql_setting "collation_server" "")
    info "character_set_server = ${charset}"
    info "collation_server = ${collation}"

    if [ "$charset" = "utf8mb4" ]; then
        ok "Encoding utf8mb4 (recommended for Nextcloud)"
    elif [ "$charset" = "utf8mb3" ] || [ "$charset" = "utf8" ]; then
        warn "Encoding utf8/utf8mb3 (old) — utf8mb4 recommended"
        recommend "Switch to utf8mb4 for full Unicode support (emojis…)"
    else
        warn "character_set_server = ${charset:-?} — Nextcloud requires utf8mb4"
        recommend "Set character_set_server=utf8mb4 and collation_server=utf8mb4_general_ci (NC handles its tables in utf8mb4 if mysql.utf8mb4=true)"
    fi

    # Transaction isolation: Nextcloud REQUIRES READ COMMITTED (documented) —
    # the REPEATABLE READ default causes deadlocks/lost updates under
    # concurrent file operations. With a binary log, ROW format goes with it.
    subheader "Transaction isolation"
    local tx_iso
    tx_iso=$(get_mysql_setting "transaction_isolation" "")
    [ -z "$tx_iso" ] && tx_iso=$(get_mysql_setting "tx_isolation" "")
    if [ "$tx_iso" = "READ-COMMITTED" ]; then
        ok "transaction_isolation = READ-COMMITTED (required by Nextcloud)"
    elif [ -n "$tx_iso" ]; then
        warn "transaction_isolation = ${tx_iso} — Nextcloud requires READ COMMITTED"
        recommend "Set transaction-isolation = READ-COMMITTED in the [mysqld] config"
    fi
    local log_bin binlog_fmt
    log_bin=$(get_mysql_setting "log_bin" "OFF")
    if [ "$log_bin" = "ON" ]; then
        binlog_fmt=$(get_mysql_setting "binlog_format" "")
        if [ "$binlog_fmt" = "ROW" ]; then
            ok "binlog_format = ROW (required with READ COMMITTED)"
        else
            warn "Binary log enabled with binlog_format = ${binlog_fmt:-?} — Nextcloud requires ROW alongside READ COMMITTED"
            recommend "Set binlog_format = ROW"
        fi
    fi

    # Slow query log
    subheader "Logging"
    local slow_query
    slow_query=$(get_mysql_setting "slow_query_log" "OFF")
    local slow_time
    slow_time=$(get_mysql_setting "long_query_time" "10")
    if [ "$slow_query" = "ON" ]; then
        ok "Slow query log enabled (threshold: ${slow_time}s)"
    else
        info "Slow query log disabled"
        recommend "Enable slow_query_log with long_query_time = 1 to detect slow queries"
    fi
}

# ==============================================================================
# SECTION 8 : System security
# ==============================================================================

audit_security() {
    header "8. SYSTEM SECURITY"

    # Brute-force protection: fail2ban OR CrowdSec (a modern alternative).
    subheader "Brute-force protection"
    local f2b_active="no" cs_active="no"
    systemctl is-active --quiet fail2ban 2>/dev/null && f2b_active="yes"
    if command -v cscli >/dev/null 2>&1 && systemctl is-active --quiet crowdsec 2>/dev/null; then
        cs_active="yes"
    fi

    if [ "$f2b_active" = "yes" ]; then
        ok "Fail2ban active"
        if fail2ban-client status 2>/dev/null | grep -qi "nextcloud"; then
            ok "Nextcloud jail configured in fail2ban"
        else
            warn "No fail2ban jail for Nextcloud"
            recommend "Add a fail2ban jail for Nextcloud, or consider switching to CrowdSec instead (community blocklist + bouncer)"
        fi
    elif [ "$cs_active" = "yes" ]; then
        ok "CrowdSec active (brute-force protection — a fail2ban alternative)"
        # A bouncer is what actually BLOCKS; without one CrowdSec only detects.
        local nb_bouncers
        nb_bouncers=$(cscli bouncers list -o raw 2>/dev/null | tail -n +2 | grep -c .)
        if [ "${nb_bouncers:-0}" -ge 1 ] 2>/dev/null; then
            ok "CrowdSec bouncer(s) present: ${nb_bouncers} (decisions are enforced)"
        else
            warn "CrowdSec has no bouncer — it detects but does NOT block"
            recommend "Install a bouncer, e.g. apt install crowdsec-firewall-bouncer-nftables"
        fi
        # CAPI = community blocklist of malicious IPs (the main value of CrowdSec).
        if cscli capi status >/dev/null 2>&1; then
            ok "CrowdSec registered with the Central API (community blocklist active)"
        else
            warn "CrowdSec not connected to the Central API — no community blocklist"
            recommend "Register with the CAPI: cscli capi register, then restart crowdsec"
        fi
        # Web console enrollment (optional — dashboard/GUI only, distinct from CAPI).
        if cscli console status 2>/dev/null | grep -qiE "enrolled|true"; then
            info "Enrolled in the CrowdSec web console"
        fi
    else
        warn "No brute-force protection active (neither fail2ban nor CrowdSec)"
        recommend "Install fail2ban (+ a Nextcloud jail) or CrowdSec (+ a bouncer)"
    fi

    # Firewall
    subheader "Firewall"
    if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then
        ok "UFW active"
    elif command -v nft &>/dev/null && nft list ruleset 2>/dev/null | grep -q "table"; then
        ok "nftables configured"
    elif command -v iptables &>/dev/null && iptables-save 2>/dev/null | grep -qE "DROP|REJECT"; then
        ok "iptables: filtering rules detected"
    else
        warn "No firewall detected or configured"
        recommend "Configure UFW or nftables"
    fi

    # Unattended upgrades
    subheader "Automatic updates"
    if dpkg -l | grep -q "unattended-upgrades" 2>/dev/null; then
        ok "unattended-upgrades installed"
        if systemctl is-active --quiet unattended-upgrades 2>/dev/null; then
            ok "unattended-upgrades service active"
        fi
    else
        warn "unattended-upgrades not installed"
        recommend "Install unattended-upgrades for automatic security updates"
    fi

    # Certbot / Let's Encrypt
    subheader "SSL certificates"
    if command -v certbot &>/dev/null; then
        ok "Certbot installed"
        local cb_out certs cb_min
        cb_out=$(certbot certificates 2>/dev/null)
        certs=$(echo "$cb_out" | grep -c "Domains:")
        info "${certs} Let's Encrypt certificate(s) managed"

        # Expiry: an ACTIVE timer doesn't prove renewals WORK — LE certs renew
        # ~30 days before expiry, so anything under 15 days means the renewal
        # has been failing for a while.
        if echo "$cb_out" | grep -q "INVALID"; then
            crit "At least one Let's Encrypt certificate is expired/invalid"
            recommend "Run: certbot renew --dry-run and inspect /var/log/letsencrypt/"
        else
            cb_min=$(echo "$cb_out" | grep -oE 'VALID: [0-9]+ day' | grep -oE '[0-9]+' | sort -n | head -1)
            if [ -n "$cb_min" ] && [ "$cb_min" -lt 15 ] 2>/dev/null; then
                warn "A certificate expires in ${cb_min} day(s) — automatic renewal is probably failing"
                recommend "Run: certbot renew --dry-run and inspect /var/log/letsencrypt/"
            elif [ -n "$cb_min" ]; then
                ok "Closest certificate expiry: ${cb_min} day(s)"
            fi
        fi

        # Check automatic renewal
        if systemctl is-active --quiet certbot.timer 2>/dev/null; then
            ok "Automatic renewal timer active"
        elif crontab -l 2>/dev/null | grep -q "certbot"; then
            ok "Certbot renewal cron configured"
        else
            warn "No automatic renewal detected"
            recommend "Check that certbot.timer is enabled"
        fi
    fi

    # Pending updates / reboot / service restarts (maintenance hygiene).
    # Complements unattended-upgrades (preventive) with the detective view: is
    # there a real backlog right now? Point-in-time signals (change daily).
    subheader "Pending updates & restarts"
    if command -v apt-get >/dev/null 2>&1; then
        local sec_pending all_pending
        sec_pending=$(apt-get -s upgrade 2>/dev/null | grep '^Inst' | grep -ci security)
        all_pending=$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst')
        if [ "${sec_pending:-0}" -gt 0 ] 2>/dev/null; then
            warn "${sec_pending} security update(s) pending"
            recommend "Apply soon: apt update && apt upgrade (prioritise security)"
        elif [ "${all_pending:-0}" -gt 0 ] 2>/dev/null; then
            info "${all_pending} package update(s) available (none flagged security)"
        else
            ok "System packages up to date"
        fi
    fi

    # Reboot required (a kernel/library upgrade is applied but not yet running).
    if [ -f /var/run/reboot-required ]; then
        warn "Reboot required (a kernel/library upgrade is pending a reboot)"
        recommend "Schedule a reboot during the maintenance window"
    fi

    # Services on stale libraries + kernel status (needrestart, read-only -b mode).
    if command -v needrestart >/dev/null 2>&1; then
        local nr ksta nsvc
        nr=$(needrestart -b 2>/dev/null)
        ksta=$(echo "$nr" | awk -F'[: ]+' '/NEEDRESTART-KSTA/{print $2; exit}')
        nsvc=$(echo "$nr" | grep -c '^NEEDRESTART-SVC')
        if [ "${ksta:-0}" -ge 2 ] 2>/dev/null; then
            warn "Kernel: a newer version is installed but not running (reboot needed)"
        fi
        if [ "${nsvc:-0}" -gt 0 ] 2>/dev/null; then
            warn "${nsvc} service(s) still running on outdated libraries"
            recommend "Restart them (e.g. needrestart -r a, or restart the listed services)"
        fi
        if [ "${ksta:-0}" -lt 2 ] 2>/dev/null && [ "${nsvc:-0}" -eq 0 ] 2>/dev/null; then
            ok "No service or kernel restart pending (needrestart)"
        fi
    else
        info "needrestart absent — can't detect services running on stale libraries"
        recommend "Install for deeper checks: apt install needrestart"
    fi
}

# ==============================================================================
# SECTION 8b : RAM budget reconciliation (no per-layer over-subscription)
# ==============================================================================

# Each section sizes its own layer independently against SERVER_RAM_BUDGET_MB.
# PHP_BUDGET_SHARE_PCT + DB_BUDGET_SHARE_PCT are kept ≤ ~90% so the individual
# "recommended" values don't already over-subscribe the budget on paper (Apache
# is only ever checked against the budget as a ceiling, not given a fixed
# share — cf. sections 3/6/7). Here we go further and sum the ACTUAL configured
# big consumers and check the total against that SAME budget (not raw physical
# RAM) — on a shared server (NC_RAM_BUDGET_PCT < 100, cf. section 1) this is
# what's actually meant to be available to THIS Nextcloud stack, consistent
# with every other section's sizing target. The values are exported by the
# sections that ran before: DB_INNODB_MB / DB_PG_MB (databases), FPM_TOTAL_MB
# (sum over all FPM pools), APACHE_MEM_MB (workers, model-aware).
audit_budget() {
    header "8b. RAM BUDGET RECONCILIATION"

    local ram_mb="${SERVER_RAM_BUDGET_MB:-${SERVER_RAM_MB:-0}}"
    local db_mb=$(( ${DB_INNODB_MB:-0} + ${DB_PG_MB:-0} ))
    local php_mb="${FPM_TOTAL_MB:-0}"
    local apache_mb="${APACHE_MEM_MB:-0}"
    local total=$(( db_mb + php_mb + apache_mb ))

    if [ "$total" -eq 0 ] || [ "$ram_mb" -eq 0 ]; then
        info "Not enough data to reconcile the budget (DB/PHP/Apache memory not all readable)"
        return
    fi

    info "Databases (InnoDB + PG shared_buffers) : ${db_mb} MB"
    info "PHP-FPM pools (Σ children × ~${FPM_PROC_MB:-50} MB)    : ${php_mb} MB"
    info "Apache workers (model-aware)           : ${apache_mb} MB"
    info "Sum of the main consumers              : ${total} MB"
    info "RAM budget (${NC_RAM_BUDGET_PCT:-100}% of ${SERVER_RAM_MB:-?} MB physical) : ${ram_mb} MB"
    info "Recommendation shares assumed: PHP ${PHP_BUDGET_SHARE_PCT:-60}% + DB ${DB_BUDGET_SHARE_PCT:-28}% = $(( ${PHP_BUDGET_SHARE_PCT:-60} + ${DB_BUDGET_SHARE_PCT:-28} ))% of the budget (rest = Apache/Nginx + OS)"

    # Keep ~15% for the OS, the page cache and per-connection DB buffers.
    local safe=$(( ram_mb * 85 / 100 ))
    if [ "$total" -gt "$ram_mb" ]; then
        crit "Main consumers (${total} MB) exceed the RAM budget (${ram_mb} MB) — OOM risk under load"
        recommend "Reduce the largest layer (usually PHP pm.max_children, then InnoDB) so the sum fits"
    elif [ "$total" -gt "$safe" ]; then
        warn "Main consumers (${total} MB) exceed ~85% of the RAM budget (${safe} MB) — little headroom"
        recommend "Trim a layer (PHP first) to keep ~15% for OS/cache/per-connection buffers"
    else
        ok "Main consumers (${total} MB) fit within the RAM budget with headroom (≤ ${safe} MB)"
    fi
}

# ==============================================================================
# SECTION 9 : Deep analysis (optional tools, read-only)
# ==============================================================================

audit_deep() {
    header "9. DEEP ANALYSIS (optional tools)"
    info "These analyses only run if the tool is ALREADY present (nothing is installed)."

    local suggestions=()

    # --- MySQL/MariaDB: mysqltuner / pt-variable-advisor (read-only) ---
    if systemctl is-active --quiet mariadb 2>/dev/null || systemctl is-active --quiet mysql 2>/dev/null; then
        if mysql -N -e "SELECT 1" >/dev/null 2>&1; then
            if command -v mysqltuner >/dev/null 2>&1; then
                subheader "mysqltuner (runtime recommendations)"
                info "Raw third-party output — focus on [!!] lines; ignore any 'EOL software' notice on recent MariaDB point releases (mysqltuner's version DB lags) and any 'Illegal division by zero' crash in the MyISAM/InnoDB section (known mysqltuner bug, harmless)."
                timeout 90 mysqltuner --nocolor --nogood --noinfo 2>&1 </dev/null | tail -n 60 | emit
            else
                suggestions+=("mysqltuner")
            fi
            if command -v pt-variable-advisor >/dev/null 2>&1; then
                subheader "pt-variable-advisor (Percona Toolkit)"
                info "Raw third-party output — prioritize 'WARN' lines over 'NOTE' (informational, often safe to leave at default)."
                local sock
                sock=$(mysql -N -e "SELECT @@socket" 2>/dev/null)
                { timeout 60 pt-variable-advisor "S=${sock}" 2>/dev/null \
                  || timeout 60 pt-variable-advisor h=localhost 2>/dev/null; } | emit
            else
                suggestions+=("percona-toolkit")
            fi
        else
            info "Database active but not queryable (auth/socket) — deep DB analyses skipped"
        fi
    fi

    # --- Apache: apache2buddy.pl (sizing from the processes' real RAM) ---
    # NB: apache2buddy.pl (Perl, maintained fork). The old apachebuddy.pl is abandoned.
    if systemctl is-active --quiet apache2 2>/dev/null; then
        local ab=""
        command -v apache2buddy >/dev/null 2>&1 && ab="apache2buddy"
        [ -z "$ab" ] && [ -f /usr/local/bin/apache2buddy.pl ] && ab="perl /usr/local/bin/apache2buddy.pl"
        if [ -n "$ab" ]; then
            subheader "apache2buddy"
            info "Raw third-party output — if it reports 'LOW UPTIME', its sizing is based on peak process count since the last restart and is unreliable until Apache has run under real load for a while."
            # We cut apache2buddy's footer ("IMPORTANT MESSAGE" / "DOMAIN EXPIRY
            # NOTICE"): off-topic and anxiety-inducing in this report.
            # shellcheck disable=SC2086
            timeout 60 $ab 2>&1 </dev/null | sed '/IMPORTANT MESSAGE/,$d' | head -n 50 | emit
        else
            suggestions+=("apache2buddy (Perl — richardforth/apache2buddy)")
        fi
    fi

    # --- Disk: iostat (await / %util) ---
    if command -v iostat >/dev/null 2>&1; then
        subheader "iostat (disk I/O, 2 samples)"
        info "Raw third-party output — the first sample is a boot-time average, use the second one; watch '%util' close to 100% (disk saturated) and high '*_await' (ms of latency per request)."
        timeout 15 iostat -dx 1 2 2>&1 | sed -n '/Device/,$p' | tail -n 20 | emit
    else
        suggestions+=("sysstat")
    fi

    # --- History: RAM/CPU peaks via sar (sysstat data already collected) ---
    if command -v sar >/dev/null 2>&1; then
        subheader "sar history (sysstat)"
        local cpu_peak mem_peak
        cpu_peak=$(sar -u 2>/dev/null | awk '/^[0-9]/ && NF>=8 {u=100-$NF; if(u>m)m=u} END{if(m)printf "%.0f",m}')
        mem_peak=$(sar -r 2>/dev/null | awk 'NR<=3{for(i=1;i<=NF;i++) if($i=="%memused") c=i} c && /^[0-9]/ {if($c>m)m=$c} END{if(m)printf "%.0f",m}')
        if [ -n "$cpu_peak" ] || [ -n "$mem_peak" ]; then
            [ -n "$cpu_peak" ] && info "CPU peak (today's data): ${cpu_peak}%"
            [ -n "$mem_peak" ] && info "Peak memory used (today's data): ${mem_peak}%"
            if [ -n "$cpu_peak" ] && [ "$cpu_peak" -gt 85 ] 2>/dev/null; then
                warn "High CPU peaks (${cpu_peak}%)"
                recommend "Check the load / consider more cores"
            fi
            if [ -n "$mem_peak" ] && [ "$mem_peak" -gt 90 ] 2>/dev/null; then
                warn "High memory peaks (${mem_peak}%)"
                recommend "Check the RAM / cache sizing"
            fi
        else
            info "sar present but no usable data (is sysstat active and collection recent?)"
        fi
    else
        suggestions+=("sysstat")
    fi

    # --- Suggestions (without installing anything) ---
    if [ "${#suggestions[@]}" -gt 0 ]; then
        local list
        list=$(printf '%s\n' "${suggestions[@]}" | sort -u | paste -sd, - | sed 's/,/, /g')
        info "Deep-analysis tools absent on this server: ${list} — installation in the \"Dependencies\" section above."
    fi
}

# ==============================================================================
# SECTION 10 : Summary and priority recommendations
# ==============================================================================

print_summary() {
    header "AUDIT SUMMARY"

    log_raw ""
    log_raw "  Server: $(hostname) — $(date)"
    log_raw "  Script: nc-audit.sh v${AUDIT_VERSION}"
    log_raw ""
    log_raw "  ${GREEN}✔ OK${NC}    : ${OK_COUNT}"
    log_raw "  ${YELLOW}⚠ WARN${NC}  : ${WARN_COUNT}"
    log_raw "  ${RED}✘ CRIT${NC}  : ${CRIT_COUNT}"
    log_raw "  ${CYAN}ℹ INFO${NC}  : ${INFO_COUNT}"
    log_raw ""

    if [ "$CRIT_COUNT" -gt 0 ]; then
        log_raw "  ${RED}${BOLD}⚠ ${CRIT_COUNT} critical issue(s) to fix as a priority${NC}"
    fi
    if [ "$WARN_COUNT" -gt 0 ]; then
        log_raw "  ${YELLOW}${BOLD}→ ${WARN_COUNT} warning(s) to review${NC}"
    fi
    if [ "$CRIT_COUNT" -eq 0 ] && [ "$WARN_COUNT" -eq 0 ]; then
        log_raw "  ${GREEN}${BOLD}✔ Everything looks well configured!${NC}"
    fi

    # --- Action plan: every recommendation replayed, CRIT first ---
    # The [SUGG] tier collects recommendations attached to info-level findings
    # (nice-to-have improvements, not problems).
    if [ "${#ACTION_ITEMS[@]}" -gt 0 ]; then
        log_raw ""
        log_raw "  ${BOLD}${BLUE}--- ACTION PLAN (recommendations by priority) ---${NC}"
        local ap_sev item i_sev i_ctx i_rec ap_n=0
        for ap_sev in CRIT WARN INFO; do
            for item in "${ACTION_ITEMS[@]}"; do
                IFS='|' read -r i_sev i_ctx i_rec <<< "$item"
                [ "$i_sev" = "$ap_sev" ] || continue
                ap_n=$(( ap_n + 1 ))
                case "$ap_sev" in
                    CRIT) log_raw "  ${RED}${ap_n}. [CRIT]${NC} ${i_ctx}" ;;
                    WARN) log_raw "  ${YELLOW}${ap_n}. [WARN]${NC} ${i_ctx}" ;;
                    *)    log_raw "  ${CYAN}${ap_n}. [SUGG]${NC} ${i_ctx}" ;;
                esac
                log_raw "     → ${i_rec}"
            done
        done
        log_raw ""
        log_raw "  NB: apply ONE change at a time and re-run the audit — several"
        log_raw "  recommendations share the same RAM budget and interact."
    fi

    log_raw ""
    log_raw "  Full report saved to: ${REPORT_FILE}"
    log_raw ""
}

# ==============================================================================
# INTERACTIVE: --tune-fpm (detect pools, ask the admin a weight for each)
# ==============================================================================

# Opt-in, terminal-only helper: lists the FPM pools and prompts the operator for
# a relative weight per instance (the weight is a HUMAN judgment — the script
# never guesses it), then splits the PHP share of the budget into a target
# pm.max_children per pool. Prints the equivalent NC_INSTANCES string for reuse.
# Strictly interactive: reads from /dev/tty, so it never runs in cron/--json/pipe.
audit_tune_fpm() {
    # Needs a controlling terminal (this mode is interactive by design).
    if ! { exec 3<>/dev/tty; } 2>/dev/null; then
        echo "ERROR: --tune-fpm needs an interactive terminal (not usable in cron/pipe/--json)." >&2
        exit 1
    fi

    local total_ram_mb budget_mb php_share proc_mb
    total_ram_mb=$(( $(grep MemTotal /proc/meminfo | awk '{print $2}') / 1024 ))
    budget_mb=$(( total_ram_mb * NC_RAM_BUDGET_PCT / 100 ))
    php_share=$(( budget_mb * ${PHP_BUDGET_SHARE_PCT:-60} / 100 ))
    proc_mb=$(fpm_avg_worker_mb)   # measured on running workers, else ~50 MB

    # Distinct pools (dedup by name, like the audit), keeping the max children.
    local pool_f pc pool_name
    local -A pool_children=()
    while IFS= read -r pool_f; do
        [ -f "$pool_f" ] || continue
        pc=$(get_ini_value "$pool_f" "pm.max_children" "0")
        [[ "$pc" =~ ^[0-9]+$ ]] || pc=0
        pool_name=$(basename "$pool_f" .conf)
        [ "${pool_children[$pool_name]:-0}" -lt "$pc" ] && pool_children[$pool_name]="$pc"
    done < <(find /etc/php/ -path "*/pool.d/*.conf" 2>/dev/null | sort)

    if [ "${#pool_children[@]}" -lt 2 ]; then
        echo "Found ${#pool_children[@]} FPM pool — --tune-fpm is for multi-instance hosts (2+ pools)." >&2
        exec 3>&- ; exit 0
    fi

    local rest_mb=$(( budget_mb - php_share ))
    local php_pct="${PHP_BUDGET_SHARE_PCT:-60}" rest_pct=$(( 100 - ${PHP_BUDGET_SHARE_PCT:-60} ))
    printf '%b\n' "${BOLD}${BLUE}=== Interactive FPM pool tuning ===${NC}" >&3
    printf 'RAM %s MB · stack budget %s%% = %s MB\n' \
        "$total_ram_mb" "$NC_RAM_BUDGET_PCT" "$budget_mb" >&3
    printf 'PHP share (%s%% of budget) = %s MB · the other %s%% (~%s MB) covers DB + web + OS\n\n' \
        "$php_pct" "$php_share" "$rest_pct" "$rest_mb" >&3
    printf '%bHow it works%b\n' "${BOLD}" "${NC}" >&3
    printf '  - You give each instance a RELATIVE weight (its importance/load).\n' >&3
    printf '    It is NOT a percentage and NOT megabytes: "2" just means twice the\n' >&3
    printf '    share of "1". Press Enter to keep the default weight of 1.\n' >&3
    printf '  - The %s MB PHP share is split by weight, then / ~%s MB per process:\n' "$php_share" "$proc_mb" >&3
    printf '        target = PHP_share x (weight / sum_of_weights) / %s\n' "$proc_mb" >&3
    printf '  - The target is a CEILING the budget allows for pm.max_children, not a\n' >&3
    printf '    value you must set: only raise a pool if it actually saturates.\n\n' >&3

    # Sorted instance names for a stable order.
    local -a names=() weights=()
    local total_w=0 w
    while IFS= read -r pool_name; do
        printf 'Weight for %s (current pm.max_children=%s) [default 1]: ' "$pool_name" "${pool_children[$pool_name]}" >&3
        read -r w <&3
        { [[ "$w" =~ ^[0-9]+$ ]] && [ "$w" -gt 0 ]; } || w=1
        names+=("$pool_name"); weights+=("$w"); total_w=$(( total_w + w ))
    done < <(printf '%s\n' "${!pool_children[@]}" | sort)

    # Compute and print the per-pool targets + the reusable NC_INSTANCES string.
    printf '\n%b\n' "${BOLD}--- Recommended pm.max_children ---${NC}" >&3
    local i target sum_target=0 nc_str=""
    for i in "${!names[@]}"; do
        target=$(( php_share * weights[i] / total_w / proc_mb ))
        [ "$target" -lt 1 ] && target=1
        sum_target=$(( sum_target + target ))
        printf '  %-22s weight %s/%s → %s  (current %s)\n' \
            "${names[i]}" "${weights[i]}" "$total_w" "$target" "${pool_children[${names[i]}]}" >&3
        nc_str="${nc_str:+$nc_str,}${names[i]}:${weights[i]}"
    done
    printf '  Σ target = %s children (~%s MB) vs PHP share %s MB\n' \
        "$sum_target" "$(( sum_target * proc_mb ))" "$php_share" >&3
    printf '\n%bReusable (non-interactive):%b\n' "${BOLD}" "${NC}" >&3
    printf '  NC_RAM_BUDGET_PCT=%s NC_INSTANCES="%s"\n' "$NC_RAM_BUDGET_PCT" "$nc_str" >&3
    printf 'Apply by setting each pool to its target pm.max_children, then restart php-fpm.\n' >&3

    exec 3>&-
}

# ==============================================================================
# MAIN
# ==============================================================================

# Runs the full audit (banner + all sections + summary). The output goes to
# stdout AND to $REPORT_FILE; in --json/--push modes we redirect it to
# /dev/null (the counters and $REPORT_FILE stay populated).
_run_audit() {
    log_raw "${BOLD}${BLUE}"
    log_raw "  ███╗   ██╗ ██████╗      █████╗ ██╗   ██╗██████╗ ██╗████████╗"
    log_raw "  ████╗  ██║██╔════╝     ██╔══██╗██║   ██║██╔══██╗██║╚══██╔══╝"
    log_raw "  ██╔██╗ ██║██║          ███████║██║   ██║██║  ██║██║   ██║   "
    log_raw "  ██║╚██╗██║██║          ██╔══██║██║   ██║██║  ██║██║   ██║   "
    log_raw "  ██║ ╚████║╚██████╗     ██║  ██║╚██████╔╝██████╔╝██║   ██║   "
    log_raw "  ╚═╝  ╚═══╝ ╚═════╝     ╚═╝  ╚═╝ ╚═════╝ ╚═════╝ ╚═╝   ╚═╝   "
    log_raw "${NC}"
    log_raw "  ${BOLD}Nextcloud server audit — ézéo${NC}  ${CYAN}v${AUDIT_VERSION}${NC}"
    log_raw "  $(date)"
    log_raw ""

    audit_dependencies
    audit_server
    audit_nextcloud
    audit_php
    audit_apache
    audit_nginx
    audit_postgresql
    audit_mariadb
    audit_security
    audit_budget
    audit_deep
    print_summary
}

# Builds the JSON envelope (counters + text report) to push to the monitor.
# Requires jq. SERVER_URL may be empty in local --json.
audit_emit_json() {
    local report
    report=$(cat "$REPORT_FILE" 2>/dev/null)
    jq -n \
        --arg server_url "${SERVER_URL:-}" \
        --arg audit_version "$AUDIT_VERSION" \
        --argjson generated_at "$(date +%s)" \
        --arg hostname "$(hostname)" \
        --argjson ok "${OK_COUNT:-0}" \
        --argjson warn "${WARN_COUNT:-0}" \
        --argjson crit "${CRIT_COUNT:-0}" \
        --argjson info "${INFO_COUNT:-0}" \
        --arg report "$report" \
        '{type:"audit", server_url:$server_url, audit_version:$audit_version,
          generated_at:$generated_at, hostname:$hostname,
          counters:{ok:$ok,warn:$warn,crit:$crit,info:$info}, report:$report}'
}

# Pushes the audit report to the monitors listed in targets-<SLUG>.conf
# (reuses the Push mode config/token). $1 = instance config file.
audit_guard_dir() { # $1 = file whose PARENT DIRECTORY is checked
    _d=$(dirname "$1")
    # Directory permissions decide who may REPLACE the file, whatever the file's
    # own mode and owner: in a writable directory a third party can unlink it and
    # drop another one, or swap it between the check below and the `.` that
    # sources it (TOCTOU). The per-file guards close the door; this one closes
    # the room it is in.
    if [ -n "$(find "$_d" -maxdepth 0 -perm /022 2>/dev/null)" ]; then
        echo "ERROR: $_d is group/world-writable — refusing to run (chmod 755 $_d)" >&2
        exit 1
    fi
    _duid=$(stat -c %u "$_d" 2>/dev/null || echo -1)
    if [ "$_duid" != "0" ] && [ "$_duid" != "$(id -u)" ]; then
        echo "ERROR: $_d is owned by uid $_duid — refusing to run (it holds files read as root)" >&2
        echo "       chown root:root $_d" >&2
        exit 1
    fi
}

audit_guard_owner() {
    local _uid
    _uid=$(stat -c %u "$1" 2>/dev/null || echo -1)
    if [ "$_uid" != "0" ] && [ "$_uid" != "$(id -u)" ]; then
        echo "ERROR: $1 is owned by uid $_uid — refused ($2)" >&2
        echo "       chown root:root $1" >&2
        exit 1
    fi
}

audit_push() {
    local conf="$1"
    command -v jq >/dev/null 2>&1 || { echo "ERROR: jq required for --push (apt install jq)" >&2; exit 1; }
    [ -n "$conf" ] && [ -f "$conf" ] || { echo "ERROR: --push requires a valid config file (e.g. /etc/ncstatuscheck/<slug>.conf)" >&2; exit 1; }
    if [ -n "$(find "$conf" -perm /022 2>/dev/null)" ]; then
        echo "ERROR: $conf is group/world-writable — refused (chmod 600 $conf)" >&2; exit 1
    fi
    # Permission bits are only half of it: a file owned by another user stays
    # rewritable by that user whatever its mode, and this one is sourced by a
    # script running as root.
    audit_guard_dir "$conf"
    audit_guard_owner "$conf" "it is sourced as root"
    # Safeguard: don't source a file that isn't an instance config
    # (e.g. the targets-*.conf file, a common mistake: url|token lines).
    if ! grep -q '^[[:space:]]*SERVER_URL=' "$conf"; then
        echo "ERROR: $conf doesn't look like an instance config (SERVER_URL missing)." >&2
        echo "       Pass the <slug>.conf file (e.g. /etc/ncstatuscheck/<slug>.conf)," >&2
        echo "       NOT the targets-<slug>.conf file." >&2
        exit 1
    fi
    # shellcheck disable=SC1090
    . "$conf"
    : "${SERVER_URL:?SERVER_URL missing in $conf}"
    : "${SLUG:?SLUG missing in $conf}"
    local targets="/etc/ncstatuscheck/targets-${SLUG}.conf"
    [ -f "$targets" ] || { echo "ERROR: targets file not found: $targets" >&2; exit 1; }
    if [ -n "$(find "$targets" -perm /022 2>/dev/null)" ]; then
        echo "ERROR: $targets is group/world-writable — refused (chmod 600 $targets)" >&2; exit 1
    fi
    audit_guard_dir "$targets"
    audit_guard_owner "$targets" "it decides where the report is sent"

    local json; json=$(audit_emit_json)
    # Escape for curl's -K config format (values sit inside double quotes where
    # backslash escapes): a token/password containing " or \ must not break it.
    curlconf_esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
    local M_URL M_TOKEN M_USER M_PASS resp rc=0
    while IFS='|' read -r M_URL M_TOKEN M_USER M_PASS; do
        [ -z "$M_URL" ] && continue
        case "$M_URL" in '#'*) continue ;; esac
        [ -z "$M_TOKEN" ] && continue
        # Push token and Basic-auth credentials go through --config on stdin
        # (-K -), never on the command line: argv is world-readable in /proc
        # by any local user while curl runs.
        resp=$({ printf 'header = "X-Push-Token: %s"\n' "$(curlconf_esc "$M_TOKEN")"
                 if [ -n "$M_USER" ]; then printf 'user = "%s:%s"\n' "$(curlconf_esc "$M_USER")" "$(curlconf_esc "$M_PASS")"; fi
               } | curl -s -K - \
                -X POST "$M_URL/push-api.php?action=push_audit" \
                -H "Content-Type: application/json" \
                --max-time 30 \
                -d "$json" 2>&1)
        if echo "$resp" | grep -q '"status":"ok"'; then
            echo "OK: audit sent to $M_URL"
        else
            echo "ERROR: audit send failed to $M_URL — $resp" >&2; rc=1
        fi
    done < "$targets"
    exit "$rc"
}

main() {
    # Check root permissions
    if [ "$EUID" -ne 0 ]; then
        echo "This script must be run as root (or with sudo)."
        exit 1
    fi

    # Mode: --json (prints the JSON), --push <conf> (sends to the monitor), or
    # default (terminal report). We strip the flag from the positionals so that
    # $1 becomes the possible Nextcloud path again.
    local push_conf=""
    AUDIT_MODE="report"
    case "${1:-}" in
        --json) AUDIT_MODE="json"; set -- "${@:2}" ;;
        --push) AUDIT_MODE="push"; push_conf="${2:-}"; set -- "${@:3}" ;;
        --tune-fpm) AUDIT_MODE="tune"; set -- "${@:2}" ;;
    esac

    # Nextcloud path: 1st positional argument (takes precedence) or NC_PATH variable.
    if [ -n "${1:-}" ]; then
        export NC_CUSTOM_PATH="$1"
    elif [ -n "${NC_PATH:-}" ]; then
        export NC_CUSTOM_PATH="$NC_PATH"
    fi

    # RAM budget allocated to the Nextcloud stack (web+PHP+DB), as a % of total RAM.
    NC_RAM_BUDGET_PCT="${NC_RAM_BUDGET_PCT:-100}"
    if ! [[ "$NC_RAM_BUDGET_PCT" =~ ^[0-9]+$ ]] || [ "$NC_RAM_BUDGET_PCT" -lt 10 ] || [ "$NC_RAM_BUDGET_PCT" -gt 100 ]; then
        echo "Invalid NC_RAM_BUDGET_PCT (${NC_RAM_BUDGET_PCT}) — expected a value between 10 and 100. Using 100." >&2
        NC_RAM_BUDGET_PCT=100
    fi
    export NC_RAM_BUDGET_PCT

    # PHP's share of the stack budget (the rest covers DB + web + OS). Default
    # 60%. Power-user override for atypical servers (DB-heavy → lower it).
    PHP_BUDGET_SHARE_PCT="${NC_PHP_SHARE_PCT:-60}"
    if ! [[ "$PHP_BUDGET_SHARE_PCT" =~ ^[0-9]+$ ]] || [ "$PHP_BUDGET_SHARE_PCT" -lt 20 ] || [ "$PHP_BUDGET_SHARE_PCT" -gt 90 ]; then
        echo "Invalid NC_PHP_SHARE_PCT (${PHP_BUDGET_SHARE_PCT}) — expected 20..90. Using 60." >&2
        PHP_BUDGET_SHARE_PCT=60
    fi
    export PHP_BUDGET_SHARE_PCT

    # DB's share of the SAME stack budget (InnoDB buffer pool / PG shared_buffers).
    # Kept independent from PHP_BUDGET_SHARE_PCT (each layer sizes itself against
    # the full budget otherwise, and PHP(60%) + InnoDB's old flat 50% rule summed
    # to 110% before anything was even measured — cf. section 8b reconciliation).
    DB_BUDGET_SHARE_PCT="${NC_DB_SHARE_PCT:-28}"
    if ! [[ "$DB_BUDGET_SHARE_PCT" =~ ^[0-9]+$ ]] || [ "$DB_BUDGET_SHARE_PCT" -lt 10 ] || [ "$DB_BUDGET_SHARE_PCT" -gt 70 ]; then
        echo "Invalid NC_DB_SHARE_PCT (${DB_BUDGET_SHARE_PCT}) — expected 10..70. Using 28." >&2
        DB_BUDGET_SHARE_PCT=28
    fi
    export DB_BUDGET_SHARE_PCT

    if [ "$(( PHP_BUDGET_SHARE_PCT + DB_BUDGET_SHARE_PCT ))" -gt 90 ]; then
        echo "Warning: NC_PHP_SHARE_PCT (${PHP_BUDGET_SHARE_PCT}) + NC_DB_SHARE_PCT (${DB_BUDGET_SHARE_PCT}) > 90% — the recommendations themselves would overshoot the budget before counting Apache/OS. Consider lowering one." >&2
    fi

    # Interactive FPM tuning: standalone, no full audit, no report file.
    if [ "$AUDIT_MODE" = "tune" ]; then
        audit_tune_fpm
        exit 0
    fi

    # Initialize the report (mktemp: safe creation, mode 0600 — see the
    # REPORT_FILE comment at the top of the script)
    REPORT_FILE=$(mktemp "/tmp/nc-audit-$(hostname)-$(date +%Y%m%d)-XXXXXX.txt") || {
        echo "ERROR: cannot create the report file in /tmp" >&2; exit 1
    }
    echo "# Nextcloud audit (nc-audit.sh v${AUDIT_VERSION}) - $(hostname) - $(date)" > "$REPORT_FILE"
    echo "" >> "$REPORT_FILE"

    if [ "$AUDIT_MODE" = "report" ]; then
        _run_audit
    else
        # Silent: stdout reserved for JSON (json) or unused (push).
        _run_audit >/dev/null
    fi

    case "$AUDIT_MODE" in
        json) audit_emit_json ;;
        push) audit_push "$push_conf" ;;
    esac
}

main "$@"