Einige Technik-Kollektive bieten Wordpress-Hosting auf Basis von Wordpress-Multisite an. Beispiele dafür sind die tollen Angebote von blackblogs.org und noblogs.org. In der Regel ist die Funktionalität von Wordpress beschränkt, so dass sich nur eine Auswahl von Themes und Plugins installieren lassen. Ein anderer Ansatz ist der Betrieb einzener Wordpress-Seiten in einem abgesichertem Hosting.
Diese Seite beschreibt die Einrichtung eines solchen Hostings. Entgegen anderen Anleitungen verwenden wir Apache, um mittels Macros die Seiteneinrichtung zu vereinfachen. Zur Absicherung verwenden wir ein chroot, basierend auf PHP-FPM.
Inhaltsverzeichnis
Einführung
Dieser Anleitung basiert auf anderen sehr gut Howtos. Für ein grundlegendes Verständnis solltest du diese auch lesen:
nickyreinert.de für einen allgemeinen Überblick
blog.kthx zum Setup-Skript
vennedey.net zu den Sicherheitimplikation von NSCD und opcache
binary-butterfly.de zur Verwendung von mknod statt den mount-binds
Variablen, Versionen und Pfade
Zur Veranschaulichung werden die nachfolgenden Variablen und Werte in der Anleitung genutzt:
Beispiel-Domain |
example.org |
Benutzerkonto |
katja |
PHP-Version |
7.3 |
Quota-Partition (ext4) |
/data |
Skript-Verzeichnis |
/usr/local/bin |
Wordpress-Speicherort |
/data/wordpress |
IP des Reverse-Proxys |
192.168.0.1 |
Bitte beachte, dass sich einzelnen Pfadangaben teilweise in mehreren Konfigurationsdateien benutzt werden. Bei Änderungen eines Pfades musst du also alle Vorkommen ändern.
Benutzerkonto zur PHP-Ausführung
Ein Systemnutzer mit eingeschränkten Rechten ist für die Auführung des PHP-Prozesses zuständig. Die Anmeldung per Passwort1 und das Anlegen eines Home-Verzeichnissen werden deaktiviert:
adduser --disabled-login --disabled-password --no-create-home --gecos katja
- Im späteren Verlauf wird das Wordpress-Verzeichnis diesem neuen Benutzerkonto übergeben.
Quotas einrichten
Disk-Quotas sorgen dafür, dass eine einzelne Wordpress-Instanz nicht den gesamten Speicherplatz auf einer Partition aufbrauchen kann. Läuft Wordpress in einem KVM-Gast muss ein Kernel installiert werden2:
apt install linux-image-amd64 quota
Anschließend werden der ext4-Partition in /etc/fstab die notwendigen Parameter übergeben:
/dev/vdb /data auto noatime,nodiratime,usrjquota=aquota.user,jqfmt=vfsv1 0 0
Die Partition mit den neuen Werten remounten:
mount -vo remount /data
Den Quota-Index erstellen und Quotas aktivieren3:
quotacheck -cum quotaon -v /data
Nun können für den neuen Nutzer die Quota-Regeln erstellt werden:
edquota katja
Weitere Hinweise dazu im Arch-Wiki
Apache: Seitenkonfiguration mit Macros
- Durch Apache-Macros ist es möglich, nur eine Seitenkonfiguration für alle Wordpress-Instanzen zu erstellen.
Die Seitenkonfiguration /etc/apache/sites-available/wordpress.conf basiert auf dem später näher erläuterten Verzeichnisaufbau. Die Konfiguration verhindert durch Rewriting die Installation von eigenen Themes und Modulen über das Dashboard4. Zudem wird eine optionale htaccess-Datei eingebunden. Damit lassen sich einzelnen Wordpress-Seiten per htacess zusätzlich schützen:
<Macro WPSite $domain $pool> <VirtualHost *> ServerName $domain SetEnv HTTPS on DocumentRoot /data/wordpress/$domain/htdocs ErrorLog /var/log/apache2/$domain.error IncludeOptional /data/wordpress/_htaccess.d/$domain.conf Include /etc/apache2/conf-available/wordpress-cache.conf # Installation von eigenen Themes und Plugins verhindern <IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{REQUEST_URI} ^/wp-admin/update.php RewriteCond %{QUERY_STRING} action=upload-(plugin|theme) RewriteRule (.*) /wp-admin/plugin-install.php [QSD,R=302,L] </IfModule> <Directory /data/wordpress/$domain/htdocs> Require all granted Options SymLinksIfOwnerMatch MultiViews IncludesNoExec AllowOverride AuthConfig FileInfo Indexes Limit Options # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-includes <IfModule mod_rewrite.c> RewriteEngine On RewriteBase / RewriteRule ^wp-admin/includes/ - [F,L] RewriteRule !^wp-includes/ - [S=3] RewriteRule ^wp-includes/[^/]+\.php$ - [F,L] RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [F,L] RewriteRule ^wp-includes/theme-compat/ - [F,L] </IfModule> </Directory> <IfModule proxy_fcgi_module> <FilesMatch ".+\.ph(ar|p|tml)$"> SetHandler "proxy:unix:/run/php/php-fpm-$pool.sock|fcgi://$domain" </FilesMatch> <FilesMatch ".+\.phps$"> Require all denied </FilesMatch> <FilesMatch "^\.ph(ar|p|ps|tml)$"> Require all denied </FilesMatch> <Files xmlrpc.php> Require all denied </Files> <Files wp-config.php> Require all denied </Files> </IfModule> </VirtualHost> </Macro> # Includieren der Variablen Include /etc/apache2/conf-available/wordpress-sites.conf UndefMacro WPSite
Alle Variablen werden in /etc/apache2/conf-available/wordpress-sites.conf definiert. Die pool-Variable bezeichnet den Namen des PHP-FPM-Pools:
# Muster: Use WPSite $domain $pool Use WPSite example.org katja
Anschließend werden alle benötigten Module und die Seite aktiviert:
a2enmod macros a2ensite wordpress.conf systecmtl reload apache2
Chroot-Verzeichnis-Struktur
Oberste Ebene eines jeden chroots ist das jeweilige Domain-Verzeichnis. Für die Beispieldomain ist es data/wordpress/example.org.
Das tmp-Verzeichnis hat eine doppelte Funktion: Es markiert ein chroot-Verzeichnis. Das ist für das später vorgestellte chroot-setup-Skript wichtig. Zudem werden dort temporäre Daten gespeichert.
Wordpress selbst liegt unter htdocs. Daraus ergibt sich folgende Struktur:
├── data/wordpress/example.org │ ├── htdocs/ │ │ └── index.php │ ├── logs/ | ├── sessions/ | └── tmp/
htdocs gehört dem oben erstellten Benutzerkonto katja:katja. Alle anderen Verzeichnisse root:root.
Chroot-Setup-Skript
Das Skript /usr/local/bin/php-fpm-chroot-setup nimmt die Ersteinrichtung des chroots und der Verzeichnisstruktur vor. Es kopiert zudem die notwendigen Daten in das chroot.
Im Wesentlichen handelt es sich um Daten für die Namensauflösung, das Überprüfen von Zertifikaten und den mysql-Socket:
set -eu BASE_DIR="/data/wordpress" CHROOT_DIRS=$(find "$BASE_DIR" -mindepth 1 -maxdepth 1 -type d) COPY_SOURCES=" /etc/hosts /etc/resolv.conf /etc/ssl/certs /lib/x86_64-linux-gnu/libnss_dns.so.2 /usr/lib/ssl/openssl.cnf /usr/share/zoneinfo" MOUNTS="/var/run/mysqld" MKNOD_DIRS=" dev logs sessions tmp" create_mounts() { local chroot_dir="$1" shift local mount_dir for mount_dir in "$@"; do if [ -d "$mount_dir" ]; then # $mount_dir ist ein Pfad zu einem Verzeichnis mkdir -p "$chroot_dir/$mount_dir" mount --bind -o ro "$mount_dir" "$chroot_dir/$mount_dir" else # $mount_dir ist ein Pfad zu einer Datei mkdir -p "$chroot_dir/$(dirname "$mount_dir")" touch "$chroot_dir/$mount_dir" mount --bind -o ro "$mount_dir" "$chroot_dir/$mount_dir" fi done } remove_chroot() { local chroot_dir="$1" shift if [ -d "$chroot_dir/tmp" ]; then echo "Loese $chroot_dir auf..." for f in "$@"; do umount "$chroot_dir/$f" || continue if [ -d "$chroot_dir/$f" ]; then # Leeren Ordner loeschen rmdir "$chroot_dir/$f" elif [ -f "$chroot_dir/$f" ]; then # Datei loeschen rm "${chroot_dir}${f}" fi done fi } if [ $# -ge 1 ]; then ACTION=$1 shift else ACTION=help fi case "$ACTION" in init) [ $# -ne 1 ] && echo "action 'init' requires one parameter: domain - e.g. 'example.org'" && exit 1 DOMAIN="$1" CHROOT_SITE_DIR="$BASE_DIR/$DOMAIN" for i in $MKNOD_DIRS; do mkdir -p "$CHROOT_SITE_DIR/$i" done for i in $COPY_SOURCES; do cp -urpL --parents "$i" "$CHROOT_SITE_DIR" done mknod -m 666 "$CHROOT_SITE_DIR/dev/null" c 1 3 mknod -m 444 "$CHROOT_SITE_DIR/dev/random" c 1 8 mknod -m 444 "$CHROOT_SITE_DIR/dev/urandom" c 1 9 mknod -m 666 "$CHROOT_SITE_DIR/dev/zero" c 1 5 mkdir -p "$CHROOT_SITE_DIR/tmp" create_mounts "$CHROOT_SITE_DIR" "$MOUNTS" ;; start) for chroot_dir in $CHROOT_DIRS; do # Nur in Ordnern mit eigenem /tmp Verzeichnis als Markierung einen Chroot aufsetzen if [ -d "$chroot_dir/tmp" ]; then # Berechtigungen von /tmp korrigieren chmod 777 "$chroot_dir/tmp" chmod +t "$chroot_dir/tmp" echo "Setting up ${chroot_dir} ..." create_mounts "$chroot_dir" $MOUNTS for i in $COPY_SOURCES; do cp -urpL --parents "$i" "$chroot_dir" done fi done ;; stop) for chroot_dir in $CHROOT_DIRS; do remove_chroot "$chroot_dir" "$MOUNTS" done ;; restart) "$0" stop 2>/dev/null "$0" start ;; remove) DOMAIN="$1" CHROOT_SITE_DIR="$BASE_DIR/$DOMAIN" remove_chroot "$CHROOT_SITE_DIR" "$MOUNTS" ;; help|--help) echo "Usage: $(basename "$0") { start | stop | restart | init | remove }" echo exit 1 ;; *) "$0" help >&2 exit 1 ;; esac
Nähere Erläuterungen zu dem Skript findest du hier.
Zudem ist hier erklärt, weshalb die Verwendung von NSCD zur Namensauflösung keine gute Idee ist. Als Alternative kopiert das Skript /etc/resolv.conf und libnss_dns.so.2.
Initieren beim Start
Einige Daten (im Beispiel der mysql-Socket) werden durch das Skript per bind-mount in das chroot gemountet. Diese müssen bei jedem Start wieder hergestellt werden. Dies übernimmt der systemd-Service /etc/systemd/system/php-fpm-chroot.service:
[Unit] Description=Set up PHP-FPM chroot mounts After=network.target php7.3-fpm.service mysqld.service [Service] Type=forking ExecStart=/usr/local/bin/php-fpm-chroot-setup start ExecStop=/usr/local/bin/php-fpm-chroot-setup stop RemainAfterExit=yes [Install] WantedBy=multi-user.target
Den Service aktivieren und den Deamon neu laden:
systemctl enable php-fpm-chroot.service systemctl daemon-reload
Konfiguration des PHP-FPM-Pools mit Chroot
Die Konfigurarion des PHP-FPM-Pools basiert auf den Pfadangaben, die in der Apachekonfiguration und in der Verzeichnisstruktur verwenden werden.
Damit das PHP-FPM-Socket gefunden wird:
php_admin_value[doc_root] = /htdocs php_admin_value[cgi.fix_pathinfo] = 0
Sicherheitseinstellugnen für opcache:
php_admin_value[opcache.validate_permission] = 1 php_admin_value[opcache.validate_root] = 1
Angabe des Cert-Pfades, damit die Validierung klappt:
php_admin_value[openssl.capath] = /etc/ssl/certs
Alles in allem die vollständige FPM-Konfiguration:
[katja] prefix = /data/wordpress/example.org user = $pool group = www-data listen = /run/php/php-fpm-$pool.sock listen.owner = $pool listen.group = www-data listen.mode = 0660 listen.allowed_clients = 127.0.0.1 pm = ondemand pm.max_children = 5 pm.start_servers = 2 pm.process_idle_timeout = 10s; pm.max_requests = 100 chroot = $prefix chdir = / security.limit_extensions = .php .php3 .php4 .php5 php_admin_value[doc_root] = /htdocs php_admin_value[cgi.fix_pathinfo] = 0 php_admin_value[opcache.validate_permission] = 1 php_admin_value[opcache.validate_root] = 1 php_admin_value[session.save_path] = /sessions php_admin_value[openssl.capath] = /etc/ssl/certs php_admin_value[disable_functions] = mail,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_exec,passthru,system,proc_get_status,proc_close,proc_nice,proc_terminate,proc_open,curl_ini,parse_ini_file,show_source,dl,symlink,system_exec,exec,shell_exec,phpinfo
Verwaltung mit wpcli
Wpcli ist ein Werkzeug für die Kommandozeile, um Wordpress-Seiten zu verwalten.
Die Installation geht recht einfach.
Zu beachten ist, dass wpcli stehts als der angelegte Wordpress-Nutzer ausgeführt wird (und nicht als root).
Seitengeschwindigkeit optimieren
Mittels Caching sollen Seitenaufrufe beschleunigt werden.
Webserver-Caching
Wir weisen den Browser an, Mediendateien, css und javascript zu cachen. Dafür wirf in /etc/apache2/conf-available/wordpress-cache.conf ein globaler Cache definiert:
<IfModule mod_expires.c> ExpiresActive on # whitelist expires rules ExpiresDefault "access 1 month" # Favicon (cannot be renamed) ExpiresByType image/x-icon "access plus 1 week" # Media: images, video, audio ExpiresByType image/gif "access plus 1 month" ExpiresByType image/png "access plus 1 month" ExpiresByType image/jpg "access plus 1 month" ExpiresByType image/jpeg "access plus 1 month" ExpiresByType video/ogg "access plus 1 month" ExpiresByType audio/ogg "access plus 1 month" ExpiresByType video/mp4 "access plus 1 month" ExpiresByType video/webm "access plus 1 month" # Webfonts ExpiresByType application/x-font-ttf "access plus 1 year" ExpiresByType font/opentype "access plus 1 year" ExpiresByType application/x-font-woff "access plus 1 year" ExpiresByType image/svg+xml "access plus 1 year" # CSS and JavaScript ExpiresByType text/css "access plus 1 month" ExpiresByType text/javascript "access plus 1 month" ExpiresByType application/javascript "access plus 1 month" <IfModule mod_headers.c> Header append Cache-Control "public" </IfModule> </IfModule>
Nun noch das entsprechende Modul neu laden und den Webserver neu laden:
a2enmod macros expires systemctl reload apache2
Redis-Caching
- Redis ist ein persistenter Objekt-Cache zur Zwischenspeicherung von Daten
In der wp-config.php muss der Cache_Salt angegeben werden. Das ist wichtig, damit bei der Verwendung von nur einer Redis-Datenbank mit mehreren Wordpress-Seiten die Inhalte des Caches korrekt zugeordnet werden können:
wp config set WP_CACHE_KEY_SALT example.org_
Die Nutzung setzt ein Plugin voraus, bspw. WP-Redis:
wp plugin install wp-redis wp plugin acticate wp-redis
Anschließend lässt sich die Redis-Nutzung per wpcli aktivieren:
wp redis enable wp redis info
Wird Redis per TCP-Verbindung angesprochen, muss nichts weiter konfiguriert werden. Soll die Verbindung über das Socket hergestellt werden, müssen die Serverangaben in wp-config.php ergänzt werden:
$redis_server = array( 'host' => '/run/redis/redis-server.sock', 'port' => 0, );
Zudem muss der Wordpress-Nutzer zur Redis-Gruppe hinzugefügt werden:
usermod -a -G redis katja
Varnish-Caching
Alternativ zu Redis kann Varnish als Caching-Lösung eingesetzt werden. Dadurch lassen sich die Caching-Einstellungen ziemlich detailliert anpassen. Die nachfolgenden Punkte orientieren sich an den Blog-Artikeln Varnish Virtual Hosts. The Right Way sowie Purge and proxies, a love-hate affair. In der Wordpress-Dokumentation ist ein umfangreiches Konfigurationsbeispiel für Varnish enthalten. Dort werden unnötige Weise viele Sachen gedopplet, die Varnish durch seine Built-in vcl_recv sowieso macht5.
Installation über die Paketquellen:
apt install varnish
Varnish lauscht anschließend auf Port 6081 auf Verbindungen.
- Da die freie Version keine SSL-Terminierung unterstützt, muss Varnish hinter einem Reverse-Proxy laufen
Die Konfiguration in /etc/varnish/default.vcl sieht so aus:
vcl 4.0; acl local { "localhost"; "192.168.0.2"; "192.168.0.1"; } import std; backend default { .host = "127.0.0.1"; .port = "80"; } sub vcl_recv { } sub vcl_backend_response { } sub vcl_deliver { unset resp.http.server; unset resp.http.via; unset resp.http.x-powered-by; unset resp.http.x-runtime; unset resp.http.x-varnish; } include "vhosts.vcl";
- Hier werden folgende Sachen konfiguriert:
Die ACL-Regel local besagt, dass der Cache nur von berechtigten Adresse gelöscht werden kann. 192.168.0.1 ist in diesem Fall der Reverse-Proxy.
Das Modul std wird import - es wird für die ACL-Regel benötigt.
Unter sub vcl_deliver wir die Auslieferung verschiedener Header unterdrückt.
Die Konfiguration von einzelnen Seiten erfolgt über die Einbindung der Datei /etc/varnish/hosts.vcl.
In /etc/varnish/hosts.vcl werden wiederum lediglich weitere Seiten inkludiert:
include "sites.d/example.org.vcl";
Im anzulegenden Verzeichnis /etc/varnish/sites.d befinden sich dann die spezielle Seitenkonfiguration example.org.vcl:
sub vcl_recv { if (req.http.host == "example.org") { set req.backend_hint = default; } include "sites.d/wordpress.vcl"; } sub vcl_backend_response { if (beresp.ttl == 120s) { set beresp.ttl = 1h; } }
- Hier werden folgende Sachen festgelegt:
- Inkludieren der allgemeinen Wordpress-Konfiguration
- Konfigurieren der Haltbarkeit der gecachten Sachen
Letzendlich sieht die sites.d/wordpress.vcl so aus:
if (req.http.Upgrade ~ "(?i)websocket") { return (pipe); } if (req.url ~ "wp-admin|wp-login|login") { return (pass); } set req.http.cookie = regsuball(req.http.cookie, "wp-settings-\d+=[^;]+(; )?", ""); set req.http.cookie = regsuball(req.http.cookie, "wp-settings-time-\d+=[^;]+(; )?", ""); set req.http.cookie = regsuball(req.http.cookie, "wordpress_test_cookie=[^;]+(; )?", ""); if (req.http.cookie == "") { unset req.http.cookie; } if (req.method == "PURGE") { if (std.ip(req.http.x-real-ip, "0.0.0.0") ~ local) { return (purge); } else { return (synth(403)); } }
Damit die ACL-Regel funktioniert und nur berechtigte IP-Adressen den Cache löschen können, muss der Reverse-Proxy den Header X-Real-IP setzen.
Zum automatischen Löschen der Caches bei Änderungen an der Seite kann das Plugin Proxy Cache Purge verwendet werden.
Abschließende Hinweise
In der obigen Apachekonfiguration wird mittels rewrite die Installation von eigenen Themes und Plugins unterbunden. Ergänzend dazu kann die Bearbeitung vorhandener Themes und Plugins im Dashboard verhindert werden:
# wp-config.php ... define('DISALLOW_FILE_EDIT', true)
- Ein paar Abweichungen von den gängigen Anleitungen zur Einrichtung von PHP-FPM seien noch erwähnt:
Das Mounten oder Kopieren von sendmail in das chroot für Mailfunktionalität: Das funktioniert so nicht. Sendmail hat weitere Abhängigkeiten und braucht Zugriff auf verschiedene Verzeichnisse. Als Aternative wird mini_sendmail vorgeschlagen. Mittels der oben dargestellten PHP-FPM-Konfiguration wird mail() eh deaktiviert. Sendmail ist also in diesem Setup nicht notwendig. Der Mailversand aus Wordpress erfolgt mittels eines SMTP-Plugins.
Das Mounten oder Kopieren von /etc/ssl/certs in das chroot: Die Zertikate alleine reichen für die volle Funktionalität nicht aus. PHP benötigt zudem Zugriff auf /usr/lib/ssl/openssl.cnf.
Hinweise und Links
Validierung von Zertifikaten im chroot debuggen
Anpassen der Benutzerrechte für PHP-PFM
Performancevergleich zwischen Memcached und Redis
Doku zum WP-Redis-Plugin
Fussnoten
Die Anmeldung per ssh-Schlüssel ist weiterhin möglich (1)
Damit die Quota-Kernelmodule verfügbar sind (2)
Falls quotaon den Fehler Devive or ressource busy anzeigt, Quota deaktivieren und erneut aktivieren (3)
Die Installation über das Wordpress Theme- und Pluginverzeichnis ist weiterhin möglich (4)
Siehe The Varnish Book, Kapitel 7.8 "Built-in vcl_recv" (5)