Come isolare i siti PHP con un pool PHP-FPM per sito
Utente dedicato, open_basedir e sessioni separate per ogni sito
Se sul vostro server girano più siti PHP, con ogni probabilità girano tutti nello stesso pool di PHP-FPM, quello di default (www), come utente www-data. Funziona, certo. Il problema è che basta un sito bucato (il WordPress col plugin non aggiornato, il vecchio gestionale che nessuno tocca dal 2016...) perché chi è entrato possa leggere i file di tutti gli altri: password dei database, chiavi delle applicazioni, dump SQL dimenticati in giro. Per PHP sono tutti file di www-data, quindi tutti leggibili.
La soluzione è dare a ogni sito il suo pool PHP-FPM:
- ogni sito gira con un utente dedicato (
web-<nome>); open_basedirchiude PHP dentro la cartella del sito;- sessioni e file temporanei stanno in una cartella privata del pool;
- i file con le password diventano leggibili solo dall'utente di quel sito.
Così, se un sito viene compromesso, il danno resta in quel sito.
In questa guida riporto quello che ho fatto io su Debian 13 con Apache 2.4 e PHP 8.5: un modello unico per tutti i pool, un sito migrato alla volta, e le problematiche in cui sono cascato (che sono la parte più utile, credetemi).
Come funziona
Apache passa i file .php a PHP-FPM attraverso un socket. Di default c'è un solo socket, quello del pool www; noi ne creiamo uno per sito, e ogni vhost manda le richieste al suo:
┌──────────────────────────┐ ╔═══════════════════════════════════╗
│ Apache, vhost di blog │ ║ PHP-FPM, pool [blog] ║
│ Define PHP_POOL blog ├───────►╢ utente web-blog ║
│ │ socket ║ open_basedir = /var/www/blog/ ║
└──────────────────────────┘ ╚═══════════════════════════════════╝
┌──────────────────────────┐ ╔═══════════════════════════════════╗
│ Apache, vhost di shop │ ║ PHP-FPM, pool [shop] ║
│ Define PHP_POOL shop ├───────►╢ utente web-shop ║
│ │ socket ║ open_basedir = /var/www/shop/ ║
└──────────────────────────┘ ╚═══════════════════════════════════╝
┌──────────────────────────┐ ╔═══════════════════════════════════╗
│ vhost non ancora migrati ├───────►╢ pool [www] (www-data), come prima ║
└──────────────────────────┘ ╚═══════════════════════════════════╝
Il bello è che i siti senza PHP_POOL continuano a usare il pool www: si può migrare un sito alla volta, provarlo con calma, e passare al successivo.
Il modello comune dei pool
Invece di scrivere un file completo per ogni pool (e ritrovarsi dopo un anno con dieci file tutti leggermente diversi), metto le impostazioni comuni in un solo file, /etc/php/8.5/fpm/pool-common.inc:
; Impostazioni comuni ai pool dei siti
; $pool = nome del pool (la sezione [nome])
user = web-$pool
group = web-$pool
listen = /run/php/php-$pool.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 30s
pm.max_requests = 500
php_admin_value[upload_tmp_dir] = /var/lib/php/sessions-$pool/tmp
php_admin_value[sys_temp_dir] = /var/lib/php/sessions-$pool/tmp
php_admin_value[session.save_path] = /var/lib/php/sessions-$pool
Il file sta fuori da pool.d/ di proposito: PHP-FPM carica come pool ogni *.conf che trova lì dentro, e questo file da solo non è un pool.
Qualche spiegazione:
$poolviene sostituito da PHP-FPM con il nome del pool in tutte le righe, anche inuser,listenephp_admin_value[...]. È questo che permette di avere un file solo per tutti.listen.owner/listen.group: il socket è del pool, ma deve poterlo usare Apache, che gira comewww-data.pm = ondemand: i processi partono solo quando arriva una richiesta e si spengono dopo 30 secondi di inattività. Con tanti pool è fondamentale, altrimenti ogni sito tiene i suoi processi in RAM anche se non lo visita nessuno.php_admin_valuee nonphp_value: i valori "admin" non si possono sovrascrivere da.htaccesso conini_set(), quindi un sito compromesso non può allargarsi l'open_basedirda solo.
Il file di ogni pool, a questo punto, è di tre righe. Per esempio /etc/php/8.5/fpm/pool.d/blog.conf:
[blog]
include = /etc/php/8.5/fpm/pool-common.inc
php_admin_value[open_basedir] = /var/www/blog/:/var/lib/php/sessions-$pool/:/var/www/notfound/
Se un sito ha bisogno di un valore diverso basta scriverlo dopo l'include: vale l'ultimo. Per esempio pm.max_children = 3 per un sito che non visita quasi nessuno.
L'open_basedir contiene la cartella del sito, quella delle sessioni del pool e /var/www/notfound/, dove tengo le pagine di errore comuni a tutti i siti (ci torniamo sotto nella sezione delle problematiche).
Una funzione per creare i pool
Per non dimenticare pezzi per strada mi sono fatto una funzione, da mettere nel .bashrc di root:
# uso: newpool blog /var/www/blog
newpool() {
local p=$1 dir
dir=$(realpath -ms -- "$2") || return 1 # niente / alla fine o doppi
[[ $p =~ ^[a-z][a-z0-9-]*$ ]] || { echo "Nome non valido"; return 1; }
[ -d "$dir" ] || { echo "Manca $dir"; return 1; }
# utente di sistema senza shell e senza home propria
useradd --system --no-create-home --home-dir "$dir" --shell /usr/sbin/nologin "web-$p"
# sessioni e file temporanei privati del pool
install -d -o "web-$p" -g "web-$p" -m 0700 "/var/lib/php/sessions-$p" "/var/lib/php/sessions-$p/tmp"
printf '[%s]\ninclude = %s\n%s%s/:%s\n' "$p" /etc/php/8.5/fpm/pool-common.inc \
'php_admin_value[open_basedir] = ' "$dir" '/var/lib/php/sessions-$pool/:/var/www/notfound/' \
> "/etc/php/8.5/fpm/pool.d/$p.conf"
php-fpm8.5 -t
}
Il nome del pool deve essere corto e senza punti (blog, non blog.miosito.it): diventa il nome dell'utente, del socket e della cartella delle sessioni.
Lato Apache
Di default Debian imposta in conf-enabled/php8.5-fpm.conf un SetHandler globale verso il socket del pool www. Nel vhost lo sovrascrivo quando è definita la variabile PHP_POOL. Io uso un file incluso da tutti i vhost, ma lo stesso blocco si può mettere direttamente nel <VirtualHost *:443>:
<IfDefine PHP_POOL>
<FilesMatch "\.ph(?:ar|p|tml)$">
<If "-f %{REQUEST_FILENAME}">
SetHandler "proxy:unix:/run/php/php-${PHP_POOL}.sock|fcgi://${PHP_POOL}"
</If>
<Else>
SetHandler None
</Else>
</FilesMatch>
</IfDefine>
Due dettagli non ovvi:
- il nome dopo
fcgi://è diverso per ogni pool: se fosse uguale per tutti, Apache potrebbe riusare la connessione di un pool per un altro; <If "-f ...">/<Else>: a PHP-FPM vanno solo i.phpche esistono davvero. Per gli altriSetHandler Nonetoglie anche il gestore globale, così Apache mostra la sua pagina 404 invece del laconicoFile not found.di PHP-FPM.
Il vhost del sito, poi, deve solo definire la variabile prima di includere il blocco:
Define PHP_POOL blog
E siccome con Define le variabili restano definite anche per i vhost che seguono, alla fine del vhost va tolta:
UnDefine PHP_POOL
Migrare un sito
Seguite in ordine alfabetico le seguenti fasi (mi dispiace, scusate):
A.ssimilare cosa PHP deve scrivere
Prima di tutto bisogna sapere quali cartelle il sito deve poter scrivere: cache, upload, log, sessioni. Un buon indizio sono i file creati di recente da www-data:
find /var/www/blog -user www-data -mtime -30 | head
B.uildare il pool e collegare il vhost
newpool blog /var/www/blog
Poi Define PHP_POOL blog nel vhost, e si applica prima PHP-FPM (che crea il socket) e poi Apache:
php-fpm8.5 -t && systemctl reload php8.5-fpm && apache2ctl -t && systemctl reload apache2
C.ollocare i permessi (dopo il reload!)
La regola che seguo:
- il codice resta di root e in sola lettura: un sito compromesso non può modificare i propri file PHP (niente webshell nascoste nel codice);
- diventano dell'utente del pool solo le cartelle che il sito deve scrivere;
- i file con le password diventano
640 root:web-<nome>: li legge il sito, non li legge nessun altro.
chown -R web-blog:web-blog /var/www/blog/cache
chgrp web-blog /var/www/blog/config.php
chmod 640 /var/www/blog/config.php
L'ordine conta: se cambiate i permessi prima del reload, il sito si rompe finché Apache lo manda ancora al pool www (che non può più leggere config.php).
D.iagnosticare che tutto funzioni
# I processi del pool girano con l'utente giusto
ps -eo user,cmd | grep 'php-fpm: pool'
# Gli altri siti (www-data) NON devono leggere le password
sudo -u www-data cat /var/www/blog/config.php
# Il sito deve poter scrivere dove serve
sudo -u web-blog sh -c 'touch /var/www/blog/cache/.test && rm /var/www/blog/cache/.test && echo OK'
E poi provare dal browser tutto quello che scrive: login (sessioni), upload, salvataggi, cache. Se compare un errore, leggetelo prima di dare la colpa al pool: quelli del pool sono Permission denied oppure open_basedir restriction in effect. Il resto, di solito, è un bug che c'era già (a me è capitato... 🤦♂️).
Anche i cron del sito vanno fatti girare con il suo utente, non come root:
runuser -u web-blog -- php /var/www/blog/cron.php
E. se non va... Come tornare indietro
Se ci dovessero essere problemi non risolvibili nell'immediato, basta togliere Define PHP_POOL dal vhost e ricaricare Apache: il sito torna sul pool www. Il pool può restare definito, senza PHP_POOL non lo usa nessuno. Ricordatevi solo di ridare i permessi di lettura a www-data.
Problematiche imboscate
Non sempre tutto va bene al primo colpo, ma nemmeno al secondo... o al terzo. Ecco alcuni problemi in cui mi sono imbattuto...
Impostare disable_functions nei pool ¹
Questa mi è costata delle ore... Mi sembrava una buona idea aggiungere al modello comune un bel php_admin_value[disable_functions] = exec,system,.... Con PHP 8.5 e OPcache (che è condiviso fra tutti i pool) succede il finimondo: PHP chiama la funzione sbagliata. Nel mio caso mkdir() finiva per eseguire file() e version_compare() diventava spl_autoload_extensions(), con errori 500 senza senso ed inspiegabili. Mi sono imbattuto in un bug assurdo, quasi psichedelico...
Se volete disable_functions, mettetelo una volta sola in php.ini, che vale per tutti i pool. E dopo una modifica ai pool che sembra non avere effetto, o se vedete errori stranissimi, usate systemctl restart php8.5-fpm (che svuota OPcache) e non reload.
- Rettifica: il bug lo hanno corretto nella 8.5.12. Valutate voi cosa fare, io mi sono scottato parecchio e aspetterò un po' prima di rifarlo...
Le pagine di errore comuni
Se usate pagine di errore comuni a tutti i siti (un ErrorDocument che punta a uno script PHP fuori dalla cartella del sito), quella cartella va aggiunta all'open_basedir. Altrimenti i 404 e i 403 dei siti migrati mostrano una pagina bianca e nel log trovate open_basedir restriction ... all.php. È il motivo della presenza di /var/www/notfound/ nella configurazione.
Lo stesso vale per qualunque file che il sito legge fuori dalla sua cartella (librerie PEAR in /usr/share/php/, report generati altrove...). Per i link simbolici conta il percorso reale della destinazione.
I commenti con #
Nei file di PHP-FPM i commenti si fanno con il punto e virgola. Con # PHP-FPM non parte proprio. Prima di ogni reload ricordatevi di testare sempre la configurazione:
php-fpm8.5 -t
Vedere i valori effettivi
Per controllare cosa ha capito PHP-FPM dopo include, $pool e sostituzioni varie, php-fpm8.5 -tt stampa la configurazione completa. Per un solo pool:
php-fpm8.5 -tt 2>&1 | sed -n '/NOTICE: \[blog\]/,/NOTICE: \[/p' \
| grep -E 'user|listen =|pm\.|php_admin'
In conclusione
Una volta fatto il modello e la funzione, migrare un sito sono cinque minuti più le prove. E la prossima volta che un vecchio plugin si fa bucare, il danno resta lì: niente password degli altri siti, niente sessioni altrui, niente dump del database.
Così potete dormire un po' (poco) più tranquilli!
Commenti
Nessun commento ancora. Sii il primo!
Lascia un commento