Diagnostiquer le réglage web / PHP / base de données par rapport au matériel
Ce script analyse, sur un serveur Nextcloud, la configuration du serveur web (Apache/Nginx), de PHP/PHP-FPM et de la base de données (PostgreSQL/MariaDB), ainsi que l'hygiène sécurité (fail2ban/CrowdSec, mises à jour système en attente), puis la compare aux ressources physiques de la machine (RAM, CPU, disque) — y compris la cohérence du budget RAM entre couches — pour proposer des recommandations d'optimisation. Il gère les hôtes multi-instances (plusieurs Nextcloud partageant une base).
🔒 Le script s'exécute en root mais ne fait que lire la configuration (aucune modification). Le résultat reste local : il s'affiche dans le terminal et un rapport texte est écrit dans /tmp. Rien n'est renvoyé au monitor. Relisez-le avant de le lancer.
Relisez-le avant de le lancer — c'est un script root, ne lancez jamais un script sans le comprendre.
Le script est public sur GitLab (consultable en ligne) : vous pouvez le récupérer directement sur le serveur en une commande — pratique pour une flotte. En pipe, passez le chemin et le budget via les variables NC_PATH / NC_RAM_BUDGET_PCT.
En une seule commande :
curl -fsSL https://gitlab.com/jp.louvel/ncstatuscheck/-/raw/master/tools/nc-audit.sh | sudo bash
Ou récupérer dans un fichier (pour relire / réutiliser) :
curl -fsSL https://gitlab.com/jp.louvel/ncstatuscheck/-/raw/master/tools/nc-audit.sh -o nc-audit.sh
Source de confiance (votre dépôt, en HTTPS) et script en lecture seule (aucune modification) : le risque est faible. Pour exécuter exactement la version relue, remplacez master par un tag ou un commit dans l'URL (figé, contrairement à la branche qui évolue).
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
Paramètres — chemin : 1er argument (… nc-audit.sh /var/www/nextcloud) ou variable NC_PATH= ; sinon auto-détecté. Budget mémoire : NC_RAM_BUDGET_PCT= (défaut 100 = serveur dédié ; abaissez-le si le serveur héberge d'autres services / instances).
sudo bash nc-audit.sh
sudo bash nc-audit.sh /var/www/nextcloud
sudo NC_RAM_BUDGET_PCT=50 bash nc-audit.sh /var/www/nextcloud
curl -fsSL https://gitlab.com/jp.louvel/ncstatuscheck/-/raw/master/tools/nc-audit.sh | sudo bash
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
sudo NC_RAM_BUDGET_PCT=70 NC_INSTANCES="poolA:4,poolB:2,poolC:1" bash nc-audit.sh
sudo NC_RAM_BUDGET_PCT=70 bash nc-audit.sh --tune-fpm
#!/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.
# ==============================================================================
# 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.13"
# 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
# Report file
REPORT_FILE="/tmp/nc-audit-$(hostname)-$(date +%Y%m%d-%H%M%S).txt"
# --- Utility functions ---
log_raw() {
echo -e "$1"
echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g' >> "$REPORT_FILE"
}
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"
((OK_COUNT++)) || true
}
warn() {
log_raw " ${YELLOW}⚠ WARN${NC} $1"
((WARN_COUNT++)) || true
}
crit() {
log_raw " ${RED}✘ CRIT${NC} $1"
((CRIT_COUNT++)) || true
}
info() {
log_raw " ${CYAN}ℹ INFO${NC} $1"
((INFO_COUNT++)) || true
}
recommend() {
log_raw " ${YELLOW}→ Recommendation:${NC} $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
php_to_mb() {
local val="$1"
val=$(echo "$val" | tr -d ' ')
if [[ "$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+')
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"
elif [ -d "/etc/php/${PHP_AUDIT_VER}/apache2" ] && apache2ctl -M 2>/dev/null | grep -qiE "php[0-9_]*_module|php_module"; then
PHP_AUDIT_SAPI="apache2"
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).
# 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)
fi
local f line
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
val=$(echo "$line" | sed -E 's/^[^=]*=[[:space:]]*//; s/[[:space:]]*;.*$//; s/[[:space:]]*$//' | tr -d '"')
fi
done
fi
if [ -z "$val" ]; then
val=$(php -r "echo ini_get('$key');" 2>/dev/null)
fi
echo "${val:-$default}"
}
# ==============================================================================
# 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
if 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} ))
[ "$nb_vhosts" -gt 1 ] && shared_signals+=("${nb_vhosts} web vhosts (excluding defaults)")
if [ "${#shared_signals[@]}" -gt 0 ]; then
warn "Potentially shared server: ${shared_signals[*]}"
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}"
local nc_user
nc_user=$(stat -c '%U' "${nc_path}/occ")
# 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
if crontab -u "$nc_user" -l 2>/dev/null | grep -q "cron.php"; then
ok "Crontab configured for ${nc_user}"
elif grep -rqs "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"
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
# 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 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 "")
if [[ "$r_host" == /* ]]; then
ping_out=$(redis-cli -s "$r_host" ping 2>/dev/null)
elif [ -n "$r_host" ]; then
ping_out=$(redis-cli -h "$r_host" -p "${r_port:-6379}" ping 2>/dev/null)
else
ping_out=$(redis-cli ping 2>/dev/null)
fi
if echo "$ping_out" | grep -q "PONG"; then
ok "Redis responds (PONG)"
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
# Check for missing indices
local missing_indices
missing_indices=$(sudo -u "$nc_user" php "${nc_path}/occ" db:add-missing-indices --dry-run 2>&1 || true)
if echo "$missing_indices" | grep -qi "missing"; 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.
# 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
missing_pk=$(sudo -u "$nc_user" php "${nc_path}/occ" db:add-missing-primary-keys --dry-run 2>&1 || true)
if echo "$missing_pk" | grep -qi "missing"; 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)
if [ "$php_major" -ge 8 ] && [ "$php_minor" -ge 1 ]; then
ok "PHP ${php_major_minor} supported by Nextcloud"
else
crit "PHP ${php_major_minor} — version too old"
recommend "Update to PHP 8.1 minimum (8.3+ recommended)"
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" -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
# OPcache RUNTIME (hit-rate, real memory) — beyond the plain config.
# NB: via CLI we read the CLI SAPI's cache; the FPM cache may differ
# (but it's the only reliable access without an FPM status endpoint).
local oc_rt
oc_rt=$(php -r 'if(function_exists("opcache_get_status")){$s=@opcache_get_status(false); if(is_array($s)&&!empty($s["opcache_enabled"])){$h=$s["opcache_statistics"]["opcache_hit_rate"]??0; $um=round(($s["memory_usage"]["used_memory"]??0)/1048576,1); $fm=round(($s["memory_usage"]["free_memory"]??0)/1048576,1); $k=$s["opcache_statistics"]["num_cached_keys"]??0; $mk=$s["opcache_statistics"]["max_cached_keys"]??0; printf("%.1f|%s|%s|%s|%s",$h,$um,$fm,$k,$mk);}}' 2>/dev/null)
if [ -n "$oc_rt" ]; then
local oc_hit oc_used oc_free oc_keys oc_maxkeys
IFS='|' read -r oc_hit oc_used oc_free oc_keys oc_maxkeys <<< "$oc_rt"
info "OPcache runtime (CLI): hit-rate ${oc_hit}%, ${oc_used} MB used / ${oc_free} MB free, ${oc_keys}/${oc_maxkeys} keys"
if awk "BEGIN{exit !(${oc_hit:-100} < 95)}" 2>/dev/null; then
warn "OPcache hit-rate low (${oc_hit}%)"
recommend "Increase opcache.memory_consumption / opcache.max_accelerated_files (also check the FPM side)"
fi
if awk "BEGIN{exit !(${oc_free:-100} < 16)}" 2>/dev/null; then
warn "OPcache nearly saturated (${oc_free} MB free)"
recommend "Increase opcache.memory_consumption"
fi
if [ "${oc_maxkeys:-0}" -gt 0 ] 2>/dev/null && [ "${oc_keys:-0}" -ge "$(( oc_maxkeys * 95 / 100 ))" ] 2>/dev/null; then
warn "OPcache: almost all keys used (${oc_keys}/${oc_maxkeys})"
recommend "Increase opcache.max_accelerated_files"
fi
else
info "OPcache runtime not readable in CLI (cache specific to FPM) — see Nextcloud → Administration → System information, or the cachetool tool"
fi
# --- 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)"
else
local fpm_conf
fpm_conf="/etc/php/${php_major_minor}/fpm/pool.d/www.conf"
if [ ! -f "$fpm_conf" ]; then
# Look for another pool
fpm_conf=$(find /etc/php/ -name "*.conf" -path "*/pool.d/*" 2>/dev/null | head -1)
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) / (average memory per process)
# We estimate ~50MB per PHP-FPM process for Nextcloud
local php_process_mb=50
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 * 50 ))
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 ~50 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 / 50 ))
[ "$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 * 50 )) 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
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
if [ "$mod" = "apcu" ] || [ "$mod" = "redis" ] || [ "$mod" = "imagick" ]; then
warn "Module ${mod} missing (recommended)"
recommend "Install php${php_major_minor}-${mod} or php-${mod}"
else
crit "Module ${mod} missing (required)"
recommend "Install php${php_major_minor}-${mod}"
fi
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
local max_workers
max_workers=$(grep -rE "^\s*MaxRequestWorkers" /etc/apache2/ 2>/dev/null | head -1 | awk '{print $2}')
if [ -n "$max_workers" ]; then
info "MaxRequestWorkers = ${max_workers}"
# 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
if apache2ctl -M 2>/dev/null | grep -qi "http2_module"; then
ok "HTTP/2 available"
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
# 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
local recommended_shared recommended_effective recommended_work recommended_maint
recommended_shared=$(( ${SERVER_RAM_BUDGET_MB:-2048} / 4 ))
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: ~25% of RAM
if [ "$shared_mb" -lt "$((recommended_shared / 2))" ]; then
warn "shared_buffers too low (${shared_buffers})"
recommend "Set to ~${recommended_shared}MB (~25% of RAM)"
elif [ "$shared_mb" -gt "$((recommended_shared * 2))" ]; then
warn "shared_buffers very high (${shared_buffers})"
recommend "Reduce to ~${recommended_shared}MB (~25% of RAM)"
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}"
if [ "$checkpoint_completion" != "0.9" ] 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
local recommended_buffer=$(( ${SERVER_RAM_BUDGET_MB:-2048} * 50 / 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 (~50-70% of RAM)"
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 (~50-70% of RAM)"
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
# 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"
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 certs
certs=$(certbot certificates 2>/dev/null | grep "Domains:" | wc -l)
info "${certs} Let's Encrypt certificate(s) managed"
# 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,
# which can over-subscribe (PHP + DB + Apache > 100%). Here we sum the ACTUAL
# configured big consumers and check the total against real RAM. 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_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 × ~50 MB) : ${php_mb} MB"
info "Apache workers (model-aware) : ${apache_mb} MB"
info "Sum of the main consumers : ${total} MB"
info "Physical RAM : ${ram_mb} MB"
# 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 physical RAM (${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 RAM (${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 RAM 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)"
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)"
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"
# 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)"
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
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
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 ))
# 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 / ~50 MB per process:\n' "$php_share" >&3
printf ' target = PHP_share x (weight / sum_of_weights) / 50\n' >&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 / 50 ))
[ "$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 * 50 ))" "$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_deep
audit_budget
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_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
# 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; }
local json; json=$(audit_emit_json)
local M_URL M_TOKEN M_USER M_PASS M_AUTH 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
M_AUTH=""; [ -n "$M_USER" ] && M_AUTH="--user $M_USER:$M_PASS"
# shellcheck disable=SC2086
resp=$(curl -s -X POST "$M_URL/push-api.php?action=push_audit" \
-H "Content-Type: application/json" \
-H "X-Push-Token: $M_TOKEN" \
--max-time 30 $M_AUTH \
-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
# Interactive FPM tuning: standalone, no full audit, no report file.
if [ "$AUDIT_MODE" = "tune" ]; then
audit_tune_fpm
exit 0
fi
# Initialize the report
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 "$@"