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:

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:

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 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:

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.

  1. 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