Diagnostiquer le réglage web / PHP / base de données par rapport au matériel
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.
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 : 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.
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
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).
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.
# 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 "$@"