IT security, FreeBSD, Linux, mail server hardening, post-quantum crypto, DNS, retro computing & hands-on hardware hacks. Privater Tech-Blog seit 2003.

Schlagwort: PHP

Wenn PHP beim Aufräumen stirbt: ein FreeBSD-rtld-Bug hinter posix_spawn

Beitragsbild zu einem FreeBSD-Bug: Laptop mit PHP- und lldb-Debug-Ausgaben, Signal-11-Core-Dump und Diagramm der Kausalkette von Nextcloud über proc_open und posix_spawnp bis zur rtld-Heap-Korruption.

Diese Geschichte fing als PHP-Problem an und endete mehrere Wochen später in einem Bug im FreeBSD-Basissystem, ganz unten im Runtime-Linker. Dazwischen liegen mindestens vier falsche Fährten, ein Crash, der bei jedem Lauf ein anderes Opfer suchte, und die schöne Erkenntnis, dass man eine Heap-Korruption nicht mit einzelnen Watchpoints fängt. Ich schreibe das bewusst mit allen Sackgassen auf, weil genau die der lehrreiche Teil sind. Wer nur die Auflösung will, springt ans Ende.

Das Symptom

PHP 8.4 auf FreeBSD 15 (amd64), im Zusammenspiel mit einer selbst gehosteten Nextcloud. Jeder occ-Aufruf und jeder Cron-Lauf lieferte sein Ergebnis korrekt ab und segfaultete danach. Signal 11, jedes Mal, mit schöner Regelmäßigkeit ein Core-Dump von rund 2,2 GB. Die Ausgabe stand vollständig da, bevor es knallte. Der Crash passierte erst im Module-Shutdown, also beim Aufräumen, nachdem die eigentliche Arbeit längst erledigt war.

Funktional war das harmlos. Ärgerlich war der Rest. Das dmesg füllte sich mit Zeilen der Sorte:

pid 12345 (php), jid 0, uid 80: exited on signal 11 (core dumped)

Die Platte lief mit 2,2-GB-Cores voll, und es gab einen unangenehmen Nebeneffekt: hängende Background-Jobs. Wenn PHP-FPM mitten in einem Nextcloud-Cron-Job segfaultet, wird das reserved_at in der Tabelle oc_jobs nie zurückgesetzt. Der Job gilt damit als dauerhaft in Bearbeitung und läuft nie wieder an. Aus einem kosmetischen Shutdown-Crash wurde so ein echtes Betriebsproblem.

Erste falsche Fährte: OPcache JIT

Ein Segfault in PHP, der frische JIT im Spiel: der erste Verdacht war schnell da. Also habe ich mich durch die JIT-Stufen gearbeitet. Tracing-JIT mit opcache.jit=1255, dann Function-JIT mit 1205, dann JIT komplett aus mit 0. Es crashte durch alle Stufen hindurch unverändert weiter.

JIT war auf FreeBSD 15 zwar tatsächlich für sich genommen kaputt und ist bei mir seitdem aus. Aber die Ursache für den Shutdown-Crash war er nicht. Erste Fährte verworfen.

Die Versions- und Build-Jagd

Nächster Verdacht: ein kaputter Build oder eine ABI-Unstimmigkeit zwischen dem PHP-Core und einer Extension. Also PHP komplett aus den Ports neu gebaut, damit Core und alle Extensions garantiert dieselbe Version tragen. Danach Symbol-Builds fürs Debugging. Und dann durch die Punktversionen gehangelt: 8.4.16, .18, .19, .20, .21, .22. Jede einzelne crashte gleich.

Damit war eine wichtige Sache geklärt: Build, Version und CFLAGS sind nicht der Unterschied. Was sich nicht ändert, wenn man alles daran ändert, liegt woanders.

Eine Lehre am Rande, die mich unnötig Zeit gekostet hat: --enable-debug wechselt das ABI-Verzeichnis der Extensions. Danach laden sämtliche als Paket installierten Extensions nicht mehr, weil sie im falschen Verzeichnis gesucht werden. Wer nur Debug-Symbole will, ohne das ABI zu verbiegen, baut so:

make CFLAGS+=" -g" STRIP=

Die Crash-Site per lldb aus dem Core

Das FreeBSD-Basissystem bringt kein gdb mit, dafür lldb. Aus dem Core kommt man so an den Backtrace:

lldb --batch -o "target create --core <core> <php-binary>" -o "bt all"

Der Stack sah beim ersten Lauf so aus:

_start → __libc_start1 → main → php_module_shutdown → zend_shutdown
  → zend_hash_graceful_reverse_destroy → destroy_zend_class +1228

Die crashende Instruktion war cmpq %rbx, 0x20(%r15). Der Offset 0x20 ist in zend_property_info das Feld ce, der Zeiger auf den Klassen-Eintrag. Das Register r15 stand auf 0x6b588e9c404, unaligned und außerhalb des Heaps. Das riecht nach einem Use-after-Free auf geteilte interne Klassen-Metadaten.

Ein genauerer Walk durch die Strukturen korrigierte meine erste Annahme. Der Offset 0x20 liegt nicht nur in zend_property_info, sondern genauso in zend_class_constant auf dem ce-Feld. Die crashende Schleife lief nicht über die Properties, sondern über die Klassen-Konstanten, also die constants_table. Die crashende Klasse war Pdo\Pgsql, eine der neuen internen Subklassen aus dem PHP-8.4-RFC zu den PDO-treiberspezifischen Subklassen, die von PDO erbt. Mein Verdacht drehte sich damit auf etwas 8.4-Spezifisches: Vererbung von internen Konstanten, vielleicht im Umfeld der Property Hooks.

Der Crash wandert

Und jetzt wurde es unangenehm. Die Crash-Site war nicht stabil. Von Lauf zu Lauf sah ich mal destroy_zend_class, mal zend_type_release, mal zend_interned_strings_dtor. Mal war das Opfer Pdo\Pgsql, mal ein arg_info von RedisCluster, mal ein zend_type, mal ein DateTimeZone.

Das ist das klassische Bild eines einzelnen korrumpierenden Schreibzugriffs mit wechselndem Opfer. Wer getroffen wird, hängt allein am Heap-Layout des jeweiligen Laufs. Das erklärt rückblickend, warum die vermeintlich genaue Klasse jedes Mal anders aussah. Ich hatte die ganze Zeit das Spätsymptom analysiert, nicht die Ursache. Als Beispiel eine ganz andere Crash-Site vom zweiten Rechner:

php_module_shutdown → zend_interned_strings_dtor
  → zend_hash_destroy +310 → _str_dtor → _efree +11

Das Opfer hier war ein permanenter interned String. Das sind die intern deduplizierten, prozessweit nur einmal abgelegten Zeichenketten, die PHP überall wiederverwendet, in diesem Lauf der Redis-Kommandoname zintercard. Sein Header war zerschossen, beim Freigeben faultet der Destruktor auf einem ZendMM-Block, der gar nicht mehr gemappt ist. Wieder ein anderer Tatort, dasselbe Muster: irgendwer schreibt einmal quer, und wer danach als Erstes über die zerstörte Stelle stolpert, nimmt den Fall.

Upstream-Issue GH-21995, und die Richtung dreht sich

An diesem Punkt habe ich das Ganze bei php-src als Issue GH-21995 aufgemacht. Zwei Reaktionen haben die Richtung gedreht.

Zuerst @iliaal, einer der PHP-Maintainer:

Cannot reproduce on Linux (ASAN, Valgrind all clean on 37 extension build), so if this is valid it might be FreeBSD specific.

ASAN und Valgrind sauber auf Linux ist ein starkes Indiz gegen einen klassischen Use-after-Free im Zend-Speichermanager. Ein solcher Fehler würde unter ASAN sofort auffliegen. Wenn er das nicht tut, sitzt das Problem woanders, vermutlich unterhalb von PHP.

Dann bestätigte @CamilleScholtz das Verhalten unabhängig, auf PHP 8.5.6, FreeBSD 15, und ausdrücklich nicht in einem Jail. Damit fielen zwei bequeme Ausreden weg: es war weder meine spezielle Konfiguration noch etwas, das in 8.5 schon behoben gewesen wäre.

Die VM reproduziert nicht, ein Heisenbug

Auf Bare-Metal crashte die unveränderte Paket-Installation praktisch bei jedem Lauf, gefühlt zu hundert Prozent. In einer VM dagegen kam ich auf rund 650 saubere Läufe, ohne einen einzigen Crash. Und sobald ich mit lldb und Watchpoints an das Objekt heranging, das ich für das Opfer hielt, verschob sich das Opfer. Die Beobachtung selbst veränderte das Heap-Layout und damit den Ausgang.

Das ist ein Heisenbug im Lehrbuchsinn. Ein einzelner Watchpoint auf ein einzelnes Objekt bringt hier nichts, weil der nächste Lauf ein anderes Objekt zerstört. Ich brauchte eine Messung, die gegen das Layout robust ist.

Messen statt raten: der Tabellen-Diff

Statt ein einzelnes Objekt zu beobachten, habe ich die ganze Tabelle der permanenten interned Strings an definierten Checkpoints verglichen. Ein eigenes lldb-Python-Skript zieht an jedem Checkpoint einen Snapshot der Tabelle und difft gegen den vorherigen. So ist es egal, welches konkrete Objekt in diesem Lauf getroffen wird, denn ich sehe jede Änderung an der ganzen Region.

Das Ergebnis war der erste harte Datenpunkt seit Wochen. Der korrumpierende Schreibzugriff passiert während des Spawns, genauer im Intervall zwischen posix_spawnp und posix_spawn_file_actions_destroy. Überschrieben wird ein zusammenhängender Block von rund 480 Byte, gefüllt mit 8-Byte-Zeigern. Das sieht aus wie Stack-Frames, die dort hingehören, wo sie nicht hingehören. Damit war klar: das ist keine PHP-interne Speicherverwaltung, das ist der Spawn.

Die Batterie: den Auslöser einkreisen

Jetzt konnte ich gezielt testen. Je 20 Läufe pro Kandidat. Nur proc_open crashte, und zwar 20 von 20. popen, exec, system, shell_exec, fopen, dazu Heap-Churn-Kandidaten wie str_repeat und range: alle 0 von 20. Es ging also nicht um fork und exec im Allgemeinen, auch nicht um Heap-Belastung, sondern spezifisch um proc_open.

Und dann entschied die Form des Aufrufs über Crash oder kein Crash:

AufrufSpawn-PfadCrash
proc_open(["true"], …) (relativ)posix_spawnp__libc_execvpe (PATH-Suche)ja
proc_open(["/usr/bin/true"], …) (absolut)posix_spawnpexecvPe direktnein
proc_open("true", …) (String)posix_spawn (/bin/sh -c) → _execvenein

Nur der relative Befehl ohne Schrägstrich im Namen crasht, weil nur der die PATH-Suche im Kind auslöst. Die Länge des PATH war dabei egal, auch mit einem einzigen Eintrag crashte es. Das grenzt es sauber gegen den alten Long-PATH-Overflow ab: es geht nicht um einen zu langen PATH, sondern um einen intrinsischen Stack-Verbrauch im no-slash-Suchzweig.

Das Minimal-Repro ist entsprechend kurz und kommt ganz ohne Framework aus:

php -r 'proc_open(["date"], [], $pipes);'   # → signal 11

Ein Detail fehlt noch, und es ist wichtig: der Crash braucht den vollen Satz geladener Extensions. Ein Minimalsatz von 17 Extensions reicht, aber das Entfernen irgendeiner einzelnen davon stoppt den Crash. Konkret dieser Satz: session, dom, iconv, imagick, intl, pdo, pgsql, phar, simplexml, sodium, xml, xmlwriter, zip, zlib, memcached, pdo_pgsql, redis. Viele geladene Shared Objects plus ein proc_open: beides zusammen ist nötig, keins allein reicht. Diese Beobachtung war später der Schlüssel zur Ursache, auch wenn ich das zu dem Zeitpunkt noch nicht wusste.

Runter in die libc-Quelle

Der Spawn führte mich in /usr/src/lib/libc/gen/posix_spawn.c. Auf amd64 startet do_posix_spawn das Spawn-Kind so:

rfork_thread(RFSPAWN, stack + stacksz, _posix_spawn_thr, &psa)

Der Stack für dieses Kind ist ein winziger, per malloc geholter Puffer:

#define _RFORK_THREAD_STACK_SIZE  4096
stacksz = 4096 + MAX(3, argc + 2) * sizeof(char *);   /* 16-Byte aligned */
stack   = malloc(stacksz);

Für ein {"true", NULL} sind das rund 4128 Byte. Das Entscheidende an RFSPAWN beziehungsweise rfork_thread: das Kind bekommt bis zum exec einen geteilten Adressraum, ähnlich wie bei vfork. Kind und Eltern arbeiten bis zum exec also auf demselben Speicher. Bei einem relativen Kommando läuft das Kind über __libc_execvpe in die PATH-Suche. Meine Hypothese an dieser Stelle war: das Kind erschöpft seine gut 4 KB Stack und schreibt in den direkt darunter liegenden Heap des Elternprozesses. Das würde exakt zu dem 480-Byte-Block aus Zeigern passen, den der Tabellen-Diff gesehen hatte.

Der Beweis: guardspawn

Eine Hypothese ist nur so gut wie ihr Experiment. Also habe ich guardspawn.c geschrieben, einen kleinen Interposer per LD_PRELOAD, der rfork_thread(RFSPAWN) abfängt und dem Kind einen selbst kontrollierten Stack unterschiebt. Zwei Varianten, zwei klare Antworten:

  • Gebe ich dem Kind 1 MB Stack, fällt der Crash auf 0 von 30. Baseline ohne Interposer war 30 von 30.
  • Gebe ich dem Kind wieder nur gut 4 KB, aber mit einer Guard-Page direkt darunter, stirbt das Spawn-Kind selbst mit SIGSEGV, unabhängig von der genauen Stelle.

Damit war die Kernaussage bewiesen: das Kind erschöpft den knapp 4 KB großen Spawn-Stack. Genauso ehrlich habe ich es aber auch in den Report geschrieben: welcher exakte Frame den Puffer überläuft, war zu dem Zeitpunkt nicht bewiesen. Ein alleinstehendes C-Programm triggerte den Fehler nicht, das Ganze hing an der Last des Prozesses. Mein Verdacht ging Richtung Runtime-Linker, aber das war noch eine Vermutung, kein Beweis.

Eine ehrliche Selbstkorrektur

Zwischendurch hatte ich mich verrannt und einen Stack-Underflow zu bestimmt behauptet. Ein zweiter, kritischer Blick von außen und ein eigener Read der Quelle korrigierten das: execvPe selbst verbraucht deutlich weniger als 4 KB, und absolute Kommandos laufen auch durch execvPe und crashen trotzdem nicht. Der Unterschied liegt also nicht in einem bewiesenen Overflow in execvPe, sondern im no-slash-Zweig der PATH-Suche. Ich habe das im Report deshalb als Lokalisierung formuliert, nicht als bewiesenen Mechanismus.

Dazu gehört auch das ehrliche Eingeständnis, dass alle meine früheren php-src-Hypothesen falsch waren: die Property Hooks, der vermeintliche Use-after-Free auf Klassen-Konstanten, die interned-String-Korruption, die pgsql-Verdächtigungen. Das war alles die wandernde Fault-Site, das Spätsymptom, nie die Ursache. Wer wochenlang das Symptom seziert, baut sich überzeugende Theorien über das Symptom. Das gehört in so einen Bericht hinein, nicht wegretuschiert.

Der Bugreport ans FreeBSD-Basissystem

Mit dieser Lokalisierung habe ich den Bug im FreeBSD-Basissystem eingereicht: Bug 295991. Das php-src-Issue GH-21995 habe ich als kein php-src-Bug geschlossen und beide Seiten miteinander verlinkt.

Wichtig war mir die Abgrenzung zu FreeBSD-SA-20:18 beziehungsweise CVE-2020-7458 von 2020. Das war der Long-PATH-Overflow an genau dieser Code-Stelle, längst behoben. Mein Fall ist die gleiche Gegend im Code, aber unabhängig von der PATH-Länge. Es ist bewusst keine Sicherheitsgeschichte, sondern ein Stabilitätsproblem, ausgelöst von völlig legitimem Code beim Aufräumen.

Praktischer Nebenbefund für alle, die sich an der Anubis-Sperre der FreeBSD-Bugzilla stören: den Status eines Bugs bekommt man ohne Browser bequem per REST:

curl -s "https://bugs.freebsd.org/bugzilla/rest/bug/295991"

Upstream pinnt die Ursache

Jetzt kam der Teil, für den sich die Mühe des sauberen Reports gelohnt hat. @bdrewery, FreeBSD-Committer, bestätigte und reproduzierte den Fehler noch bequemer als ich, direkt über den www/nextcloud-Port mit occ status in einer Schleife:

there is some random corruption that shows up with php on exit when loaded with many extensions. Raising the stack size in posix_spawn avoids the problem.

Zur Ehrlichkeit gehört der Seitenhieb, den ich mir dabei eingefangen habe: den Text meines Reports nannte er einen unreadable AI mess. Inhaltlich hat er den Fall getroffen, die Form hat genervt. Das war eine gute und verdiente Lektion über Report-Stil, auf die ich am Ende noch einmal zurückkomme.

@kevans hat den Mechanismus dann endgültig festgenagelt, und zwar an einer Stelle, an der ich nur einen Verdacht hatte. Nicht execvPe sprengt den Stack, sondern der Runtime-Linker beim Lazy-Binding der Symbole. Der Pfad ist _rtld_bindfind_symdefsymlook_defaultdonelist_init. Und donelist_init macht ein alloca, dessen Größe mit der Zahl der geladenen Shared Objects skaliert:

#define donelist_init(dlp) ((dlp)->objs = alloca(obj_count * sizeof(dlp)->objs[0]), assert((dlp)->objs != NULL), (dlp)->num_alloc = obj_count, (dlp)->num_used = 0)

Genau deshalb triggern schwer gelinkte Prozesse den Fehler und Spielzeug-Programme nicht. obj_count ist bei PHP mit dem vollen Extension-Satz groß, das alloca entsprechend fett, und auf dem gut 4 KB kleinen Spawn-Stack ist dann Schluss. Das deckt sich exakt mit meiner rtld-Vermutung aus dem Report und erklärt auch das 17-Extensions-Minimum: unter einer gewissen Zahl geladener Objekte bleibt das alloca klein genug.

Der Fix

Der Fix kam von @kib als Diff D57908. Die erste Revision regressierte und ließ eine www/onlyoffice-Umgebung crashen, mit ld-elf.so.1-Faults in beam.smp und x2t. Das war ein Multithreading-Problem, das kib noch vor dem Commit behoben hat. Danach ging es nach main:

  • 1e370f0 „rtld: stop using unbound alloca()“ vom 29. Juni 2026. Die alloca-Aufrufe in der DoneList und in map_object wandern in den Heap, sobald sie groß werden. Vermerk MFC after: 1 week.
  • 3de9dc5 vom 30. Juni 2026. Ein libc-Regressionstest, der eine Dummy-Shared-Library mehrfach mappt und mit einer Guard-Page arbeitet, um den Underflow zuverlässig zu triggern.

Beim Schreiben dieses Beitrags steht der MFC nach stable/15 an. Für ein 15.1-RELEASE kommt der Fix mit einem der künftigen 15.x-Patches. Bis dahin ist der Workaround simpel: absolute Pfade in proc_open vermeiden den crashenden no-slash-Zweig. Das ist Symptombekämpfung, kein Fix. Und wer nur das volllaufende Dateisystem im Blick hat, räumt die harmlosen Cores einfach weg.

Warum am Ende alles zusammenpasst

Das Schöne an der Auflösung ist, dass sie jedes einzelne der vielen Rätsel erklärt, die mich wochenlang in die Irre geführt haben:

  • Nur proc_open crasht, weil es das einzige PHP-Konstrukt ist, das posix_spawnp nutzt.
  • Nur relative Kommandos crashen, weil nur sie die PATH-Suche und damit das Lazy-Binding im Kind auslösen.
  • Nur FreeBSD auf amd64, weil der rfork_thread-Pfad mit dem kleinen malloc-Stack amd64- und i386-spezifisch ist.
  • ASAN und Valgrind sauber auf Linux, weil glibc posix_spawn ganz anders baut.
  • Der volle Extension-Satz nötig, weil viele Shared Objects das alloca im rtld erst groß genug für den Überlauf machen. Und die vielen permanenten interned Strings legen zusätzlich die späteren Opfer genau unter den Spawn-Puffer.

Zur Methode, und zum Report-Stil

Zwei Dinge nehme ich technisch mit. Erstens: eine Heap-Korruption mit wanderndem Opfer fängt man nicht mit einzelnen Watchpoints, weil das Beobachten das Layout verschiebt und damit das Opfer. Was funktioniert, sind layout-robuste Tabellen-Diffs an definierten Checkpoints. Nicht ein Objekt anstarren, sondern die ganze Region vorher und nachher vergleichen. Zweitens: ein LD_PRELOAD-Interposer mit Guard-Page ist ein billiges, definitives Ja-oder-Nein-Experiment für die Frage, ob ein Stack-Overflow vorliegt. Ein sauberes Experiment schlägt zehn plausible Theorien.

Und dann die Lektion, die mir @bdrewery verpasst hat. Ein Bugreport, der die ganze Hypothesenkette in den Body kippt, ist für den Leser eine Zumutung, egal wie korrekt die Analyse ist. Die richtige Form sind drei bis vier Sätze Kern ganz oben, das reproduzierbare Minimal-Beispiel gleich dahinter, und der ganze Ermittlungskrimi darunter für die, die ihn brauchen. Der Inhalt hat gestimmt, deshalb wurde der Bug gefixt. Aber die Form hätte den Committern viel Zeit gespart. Nächstes Mal Kern zuerst.

Ähnliche Geschichte im Notebook, im Basissystem festgefahren, oder einfach eine Meinung zum Report-Stil? Dann einfach fragen.

grav-plugin-fediverse-publisher: ActivityPub für Grav-Blogs, neun Iterationen bis v0.1.0

Illustration eines Grav-Plugin-Adminbereichs, von dem ActivityPub-Beiträge über ein föderiertes Netzwerk an verschiedene Fediverse-Instanzen verteilt werden.

WordPress hat seit Jahren das wunderbare wordpress-activitypub von Matthias Pfefferle und Automattic. Damit wird ein WordPress-Blog zu einem nativen Mastodon-Account, jeder Beitrag landet in den Timelines der Follower, Likes und Reposts kommen zurück. Für Grav gab es genau das nicht. Die Website meiner Frau läuft auf Grav, sie schreibt hin und wieder fachlich, und seit längerem wollte ich diesen Blog vernünftig ins Fediverse bringen. Bisher half feed2toot, also RSS in Mastodon-Posts übersetzt, das funktioniert zwar, ist aber kein ActivityPub. Keine Profilseite, keine Follower-Beziehung, kein nativer Hashtag-Index, keine saubere Article-Card. Also will ich versuchen selbst etwas zu schreiben. Das Ergebnis heißt grav-plugin-fediverse-publisher, ist seit ein paar Tagen als v0.1.0 draußen und läuft auf ihrer Webseite produktiv.

Grav-Admin Plugin-Liste mit aktiviertem Fediverse Publisher v0.0.9 zwischen Form, Login und Markdown Notices
Im Grav-Admin reiht sich der Fediverse Publisher unaufgeregt zwischen Form, Login und Markdown Notices ein. Aktiviert, Version 0.0.9 im Screenshot, das ist genau die Iteration in der es zum ersten Mal richtig sauber durchlief.

Das Repo liegt auf GitHub unter Kernel-Error/grav-plugin-fediverse-publisher (MIT). Release v0.1.0 inklusive Changelog gibt es hier. Eine Vorstellung mit Bitte um Feedback liegt im Grav-Discourse-Forum: I tried to build an ActivityPub plugin for Grav.

Warum überhaupt, und warum jetzt

Im Grav-Forum gibt es einen Thread aus dem Jahr 2019: Grav & ActivityPub. Dort hat über sechs Jahre hinweg dreimal jemand explizit nach genau dieser Funktion gefragt. Antworten gab es kaum, Code gar nicht. Das ist die Sorte Lücke die ich charakteristisch finde für kleinere Open-Source-Ökosysteme: alle finden es gut, niemand setzt sich hin. WordPress hatte über Jahre dieselbe Situation, bis Pfefferle das wordpress-activitypub-Plugin gebaut und Automattic später übernommen hat. Für Grav ist niemand vorbeigekommen.

Bei mir kam dazu, dass ich einen echten produktiven Anwendungsfall habe. Nicole, meine Frau, betreibt einen Grav-Blog im Rahmen ihrer weiteren Ausbildung. Inhaltlich vollkommen anders gelagert als alles was hier üblicherweise federiert, aber genau deshalb auch ein wertvoller Stress-Test: andere Zielgruppe, andere Empfängerinstanzen, anderes Hashtag-Vokabular. Wenn das Plugin dort sauber läuft, läuft es überall.

Phase 0: Machbarkeitscheck mit Scope-Disziplin

Bevor eine Zeile produktiver Code entstand, gab es eine Phase 0: gibt es die Lücke wirklich, ist das PHP-Bibliotheks-Ökosystem für ActivityPub brauchbar, und schaffe ich die langfristige Wartung als Solo-Entwickler? Verdikt: ja, aber nur als Broadcast-only-MVP. Konkret heißt das, der Blog kann senden, also Beiträge als Create-Activity an alle Follower-Inboxes ausliefern, sowie auf Standard-ActivityPub-Queries antworten (Actor-Profile, Outbox, Followers, NodeInfo, WebFinger, HTTP-Signaturen rein und raus). Nicht im Scope sind Replies als Kommentare zurück in Grav, Multi-Actor-Setups, Authorized Fetch und Theme-seitige Patches. Diese Disziplin durchzuhalten war im Verlauf ein paar Mal anstrengend, hat sich aber durchweg ausgezahlt.

Vor der ersten Code-Zeile entstanden vier ADRs (Architecture Decision Records) zu Storage, HTTP-Signaturen, asynchronem Push und Content-Negotiation. Diese ADRs habe ich zweimal kritisch gegenlesen lassen. In Runde zwei kamen drei Lücken zum Vorschein, die ich allein übersehen hätte. Die ungemütlichste: landrok/activitypub verifiziert beim Parsen verlinkter Objekte still im Hintergrund Netzwerk-I/O. Das hätte die gesamte SSRF-Härtung des Inbound-Pfads ausgehebelt. Konsequenz: landrok nur für die Erzeugung von AS-2.0-Objekten und WebFinger benutzen, der gesamte sicherheitsrelevante Inbound-Pfad geht nicht mehr durch landrok. Phpseclib übernimmt die Krypto direkt, der HTTP-Signature-Verifier ist eigene Implementierung. Insgesamt sind über die ADR-Reviews und ein nachgelagertes Review der ersten Implementierung acht sicherheitsrelevante Fixes eingeflossen, bevor das Plugin produktiv ging.

Konfigurationsseite des Fediverse-Publisher-Plugins im Grav-Admin mit Local-Actor-Feldern, Blog-Scope, Article-Threshold und Canonical-Host-Eintrag
Die ganze Konfiguration auf einer Seite. Lokaler Actor, Avatar- und Header-URL, ein Blog-Path-Filter und der Canonical Host. Das war einer der früh festgelegten Designgrundsätze: ein Admin-Screen, alles in Klartext, kein eigenes UI-Framework.

Stack-Entscheidungen

Die wichtigsten Bauklotz-Entscheidungen in Stichworten:

  • landrok/activitypub für die AS-2.0-Objekte und WebFinger
  • phpseclib/phpseclib für die Krypto direkt, ohne Umweg über landrok auf dem Inbound-Pfad
  • HTTP-Signaturen nach draft-cavage-12, für Sign und Verify selbst implementiert
  • SQLite per PDO im WAL-Modus für Follower-Tabelle und Push-Queue
  • Synthetic Endpoints via onPluginsInitialized, ein Pattern aus grav-plugin-form, mit PSR-7-Responses statt Symfony HttpFoundation
  • Outbound-Queue mit echtem Idempotenz-Anker per INSERT OR IGNORE auf (activity_id, recipient_inbox)
  • Retry-Schedule 1m / 5m / 30m / 2h / 12h / 24h mit Jitter, Cap bei sieben Versuchen, danach status='dead'
  • SSRF-gehärteter keyId-Fetch: HTTPS-only, Block privater IP-Bereiche, keine Redirects, 64 KiB Response-Cap, Negative-Cache
  • Strikte Identity-Bindung: keyId, publicKey.owner und activity.actor müssen nach Normalisierung auf dieselbe Actor-URL zeigen

Wer den vollen technischen Aufschrieb sucht, findet die ADRs als Markdown-Dateien im Repo unter docs/adr/. Die sind als Lese-Doku formuliert, nicht als Mitschrift.

Neun Iterationen in zwei Tagen, jede mit echtem Erkenntnisgewinn

Wenn jemand fragt, warum ein scheinbar geradliniges Plugin neun Patch-Versionen gebraucht hat: weil jede einzelne dieser Versionen eine echte Produktions-Erkenntnis war, die ich nicht in einem theoretischen Vorab-Design hätte antizipieren können. Hier in kompakt, weil die Liste für sich spricht:

  • v0.0.1: Boot-Crash auf der Produktion. Composer hat psr/log v3 reingezogen, Grav 1.7 bringt aber v1. Resultat: ganze Site HTTP 500, und das obwohl das Plugin noch gar nicht aktiviert war. Grav ruft autoload() bei jedem installierten Plugin schon beim Boot auf.
  • v0.0.2: psr/log auf ^1.1 gepinnt, defensives try/catch im Plugin-Entry, Preflight-Check via explizitem require_once statt Autoload-Pfad.
  • v0.0.3: SQL-Quoting-Falle. PHP 8.3 mit aktuellem libsqlite parst Double-Quoted-Strings als Identifier, nicht als String-Literale. Klassischer Fall, früh genug erkannt, danach konsequent Single-Quotes überall. Im selben Schritt das Listing-Page-Filter geschärft, dazu Actor-Endpoints fertig gemacht.
  • v0.0.4: Router-Wiring vergessen. Die Routen für followers und following waren im Code zwar vorhanden, aber nicht registriert. Mastodon zeigte hartnäckig „0 followers“ obwohl reale Follower in der DB lagen. Plus Diagnostics-Logging für Outbound-401-Antworten, damit ich die nächste Klasse von Bugs überhaupt sehen konnte.
  • v0.0.5: psr/log-Konflikt war tiefer als gedacht. Die Default-Einstellung autoload(prepend=true) hat den Plugin-eigenen Vendor-Pfad über Grav 2.0s gebundeltes psr/log v3 gezogen, was unter Grav 2.0 in ganz neue Fehlerbilder kippte. Fix: prepend=false. Im selben Schritt ein neues, mandatorisches canonical_host-Config-Feld eingeführt, sonst signiert die Cron mal eben keyId=http://localhost/..., was kein Empfänger akzeptiert. Ab hier läuft im Dev parallel ein zweiter Container mit Grav 1.7.52 und PHP 8.3.31 (matcht die Produktion byte-genau). Dieser Dual-Grav-Stack fängt seitdem alle Bugs der Klasse „passt auf einer Major-Version, kracht auf der anderen“ lokal ab, statt sie über Nicole laufen zu lassen.
  • v0.0.6: AS-2.0-Spec-Violation. Bei rückdatierten Posts war updated < published, was strenge ActivityPub-Implementierungen wie GoToSocial und Pleroma silently droppen. HTTP 202 vom Empfänger, aber kein Eintrag in der Timeline. Fix war eine Zeile Clamp, der Fehler dahinter aber lehrreich: ein 2xx-Status sagt zu Federation-Erfolg gar nichts.
  • v0.0.7: der echte Showstopper. Grav-Admin 1.10 und neuer routet Page-Saves nicht mehr durch onAdminAfterSave, sondern durch onFlexAfterSave (über das Flex-Objects-Plugin). Folge: User speichert einen Beitrag im Admin, der Save feuert, das Plugin macht nichts, kein Queue-Eintrag, keine Federation. Im selben Release dann auch AS-2.0-Hashtag-Federation hinzugefügt. Hashtags sind in der Praxis der primäre Discovery-Pfad für einen Actor mit null Followern, weil Mastodons Hashtag-Index fremde Actors automatisch aufnimmt. Drittens kam ein neues broadcast:post-CLI-Kommando rein, das einen einzelnen Pfad in die Queue legt, für Recovery und Backfill.
  • v0.0.8: Hotfix nach Hotfix. Meine Diagnostik-Zeile in v0.0.7 hat iterator_to_array($event) auf RocketThemes Event-Klasse aufgerufen. Die implementiert ArrayAccess, aber nicht Traversable. Ergebnis: TypeError bei jedem Admin-Save, HTTP 500, Nicole sah eine 624 KB große Fehlerseite. Diese Klasse Bug ist genau die Sorte, die das „passt schon“-Gefühl belohnt. Fix: iterator_to_array raus, Logik in eine testbare PageSaveDiagnostics-Klasse extrahiert, 13 neue Unit-Tests die alle möglichen Event-Object-Shapes durchfuzzen. Wenn schon dasselbe Problem mehrfach, dann wenigstens mit Tests dagegen.
  • v0.0.9: stale Collection. Auch nach v0.0.8 hat der Broadcaster bei frischen Posts nicht gefeuert. Ursache: $pages->find() arbeitet auf einem Pre-Save-Snapshot der Pages-Collection. Frisch gespeicherte Inhalte sind zu dem Zeitpunkt noch nicht drin. Fix: eine neue findByPage(PageInterface)-Methode, die direkt das Event-Payload nimmt, statt durch die stale Collection zu spazieren. Plus per-bail INFO-Logging: jede Regression dieser Klasse ist seither ein einziger grep entfernt von actionable. Mit v0.0.9 lief es dann durch, sauber, unter Last, an echten Followern auf realen Instanzen.

v0.1.0 ist im Wesentlichen v0.0.9 mit ehrlichem README, einem Changelog der die ganze Geschichte oben dokumentiert, und dem Vorstellungspost im Grav-Forum. Mir war wichtig, das Ding nicht als „stable“ zu vermarkten. Es ist Early Access, ich bin solo, ich brauche Tester.

Methodisch: drei Dinge die den Unterschied gemacht haben

Aus den neun Iterationen kann man ein paar Schlüsse ziehen, die ich selbst spannender finde als die einzelnen Bugs.

Erstens, der Dual-Grav-Dev-Stack. Zwei Container, derselbe Plugin-Source, einmal Grav 1.7.52 und einmal Grav 2.0 RC. Eingerichtet als Reaktion auf das psr/log-Drama in v0.0.5. Seitdem werden Bugs der Klasse „passt unter Major X, crasht unter Major Y“ lokal sichtbar, bevor sie Nicole erreichen. Das ist im Tagesgeschäft die langweiligste Investition, die ich gemacht habe, und gleichzeitig die wertvollste.

Zweitens, ein dedizierter Production-Feedback-Loop. Deployment und Smoke-Test auf der Live-Site liefen über einen sauber getrennten Track. Dort wird das Plugin installiert, gegen mastodon.social und bonn.social geprüft, eine Feedback-Notiz zurück in das Planungs-Verzeichnis geschrieben. Lokal wird daraufhin triagiert, gefixt, die Patch-Version gebumpt, gepusht. Beide Tracks haben klare Scopes: Deploy-Zugang und Auth bleiben dort wo das auch tatsächlich passiert, Architektur-Wissen und Code-Änderungen im anderen Bereich. Klingt nach Overhead, war aber der Grund, warum ich Bugs wie das onFlexAfterSave-Routing-Problem aus v0.0.7 überhaupt in akzeptabler Zeit gefunden habe: der Live-Blog war der Detektor, nicht meine lokale Dev-Maschine.

Drittens, externes Gegenlesen an sicherheitskritischen Stellen. HTTP-Signaturen, Inbox-Verifikation, SSRF-Härtung, Identity-Binding. Überall wo ein Fehler in einer realen Verwundbarkeit landet, ging der Code durch eine zusätzliche Review-Runde mit eigenem Blickwinkel. Vier potenzielle Regressionen sind dabei aufgefallen, die ich allein gemacht oder übersehen hätte. Ein zweites Augenpaar ersetzt kein Sicherheits-Audit, hebt aber das untere Drittel der „hätte mir auch auffallen können“-Klasse Fehler ziemlich zuverlässig nach oben.

Wie das für einen Mastodon-User aussieht

Mastodon-Profil von @nicole@www.beratung-rheinbach.de mit Header-Bild, Bio, Registrierungsdatum, 12 Beiträgen und 2 Followern
Aus Sicht eines Mastodon-Users ist das ein ganz regulärer Account: Header-Bild, Avatar, Bio, Beiträge, Follower-Counter, Folgen-Button. Nichts verrät, dass dahinter kein Mastodon-Server steht, sondern ein Grav-Blog mit ein paar tausend Zeilen PHP drumherum.

Genau das war das Ziel. Ein Mastodon-User abonniert @nicole@www.beratung-rheinbach.de, drückt auf Folgen, und sieht ab dem nächsten Beitrag jeden neuen Artikel direkt in der Home-Timeline. Avatar und Header werden gezogen, Bio steht da, die Profilseite zeigt alle bisher federierten Beiträge, und die Hashtags am Ende jedes Posts landen im Hashtag-Index der Empfängerinstanz. Letzteres ist nicht kosmetisch. Für einen Account mit null Followern ist der Hashtag-Index der primäre Discovery-Pfad, weil Mastodon dabei auch Posts fremder Actors mitnimmt, die zum gleichen Tag federieren.

Einzelner Blogpost zu Pausen aus dem Grav-Blog Beratung Rheinbach, in der Mastodon-Timeline mit Featured Image, Excerpt und Hashtags beratung, systemischeberatung und pausen
Ein konkreter Artikel in der Mastodon-Timeline. Header-Bild aus dem Grav-Asset, Titel, Excerpt, drei Hashtags und ein Klick-Link zurück auf den Originalbeitrag. Nicht beeindruckender als bei wordpress-activitypub, und genau das ist der Punkt.

Die End-to-End-Latenz vom Speichern im Grav-Admin bis zur Anzeige in der Heimat-Timeline eines Followers liegt bei rund 15 Sekunden. Das ist der asynchrone Push aus der SQLite-Queue plus der üblichen ActivityPub-Delivery. Fällt eine Instanz aus, retryed das Plugin nach Schedule. Erreicht der Push nach sieben Versuchen kein 2xx, markiert die Queue den Eintrag als dead und lernt aus dem letzten Statuscode, ob die Instanz dauerhaft weg ist oder ob es nur ein vorübergehendes Problem war.

Was geplant ist, und was bewusst draußen bleibt

Roadmap für v0.2, ohne festes Datum:

  • Update-Activity bei Re-Saves. Auf Mastodon kosmetisch, für Pleroma und Misskey möglicherweise relevant, da diese die Post-Card neu ziehen.
  • Delete-Federation. Aus Compliance-Sicht eine sinnvolle Komplettierung.
  • push:purge-old-activities als Housekeeping-CLI, damit die SQLite-Datei nicht ewig wächst.
  • Breitere Peer-Tests gegen Friendica und Misskey, um die HTTP-Signatur-Verifikation gegen weitere Implementierungen abzusichern.

Was explizit nicht kommt, jedenfalls nicht als integraler Plugin-Bestandteil:

  • Replies aus dem Fediverse als Kommentare zurück in den Grav-Blog. Sehr beliebte Wunschfunktion, aber sie sprengt das Broadcast-only-Scope und bringt eine ganze eigene Klasse von Spam-, Moderations- und Persistenz-Fragen mit, die ich nicht halbgar lösen will.
  • Multi-Actor pro Grav-Instanz. Ein Blog ist ein Actor, fertig.
  • Authorized Fetch. Ist aktuell ohnehin nur eine Untermenge der Mastodon-Welt, und die Kosten in Komplexität stehen nicht im Verhältnis zum Nutzen für ein Plugin in diesem Stadium.
  • Theme-seitige Patches. Das Plugin hängt sich nicht in die Twig-Templates des Blogs.

Die nächsten Schritte sind bewusst reaktiv. Statt einer langen Vorab-Roadmap warte ich, was aus der Community zurückkommt. Erste Reaktion im Discourse-Thread: ein User schlägt Software-Release-Changelogs als zweiten Anwendungsfall vor. Anderer Content-Shape als ein normaler Blog-Broadcast, aber technisch derselbe Page-Save-zu-Create-Activity-Pfad. Notiert für v0.2. Im alten 2019er-Thread habe ich ebenfalls einen Cross-Link gesetzt, damit Leute die nach Grav und ActivityPub googeln in der ersten Trefferzeile beim Repo landen statt bei sechs Jahre alten Fragen ohne Antwort.

Ehrliche Einordnung

Das Plugin läuft, aber es läuft auf einer Grav-Instanz unter genau einer Konfiguration mit zwei test Followern auf zwei Mastodon-Instanzen. Das ist ein winziger Ausschnitt aus dem, was Fediverse so an Implementierungen, Versionen und Edge-Cases zu bieten hat. Ich habe gegen die ActivityPub-Spec implementiert, gegen die HTTP-Signature-Drafts gelesen und gegen Mastodons faktisches Verhalten validiert. Pleroma- und Misskey-Spezifika sind nur sekundär berücksichtigt, Friendica gar nicht. Wer das Plugin auf einer eigenen Grav-Installation ausprobiert und gegen seine Lieblings-Mastodon-Heimat-Instanz federiert, hilft mir mehr als jeder weitere Unit-Test, den ich allein schreiben kann.

Wenn etwas nicht läuft, ist ein Issue im Repo der direkteste Weg. Eine Antwort im Forum-Thread erreicht zusätzlich auch andere Grav-Anwender die womöglich Ähnliches sehen. Über die Kontaktseite hier auf dem Blog komme ich genauso an Mails.

Bleibt eine kleine Beobachtung für alle, die sich an ähnliche Projekte heranwagen. Zwei Tage Iterationen, neun Patch-Releases, ein Production-Inzident mit der 624-KB-Fehlerseite. Das ist genau der Realismus, den ein Plugin im ersten echten Einsatz mitbringt. Es wäre bequemer, das im Changelog wegzulassen und v0.1.0 als unbefleckte erste Veröffentlichung zu inszenieren. Mir war es wichtiger, die Story so zu erzählen, wie sie ablief, weil ich diesen Schreibstil bei anderen Open-Source-Projekten selbst sehr schätze.

Siehe auch: ts3level (anderes Solo-Projekt, andere Domain, gleicher Workflow).

Fragen, Kommentare, Bug-Reports, oder einfach Lust auf einen kurzen Austausch zum Plugin gerne über die fragen-Seite oder direkt als Issue im Repo.

© 2026 -=Kernel-Error=-RSS

Theme von Anders NorénHoch ↑