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

Schlagwort: SelfHosted (Seite 1 von 8)

ECH aktivieren: Encrypted Client Hello in nginx mit OpenSSL 4.0, sechs Domains und ein gemeinsamer Deckname

Der Servername im TLS-Handshake steht bis heute im Klartext im Netz, unabhängig davon, wie gut die DNS-Anfrage davor verschlüsselt war. Wer DNS over HTTPS oder DNS over TLS einsetzt, verbirgt den Lookup selbst, aber sobald der Browser die eigentliche Verbindung aufbaut, nennt das Client Hello die besuchte Domain im Server Name Indication Feld offen. Jeder Netzwerkbeobachter zwischen Client und Server sieht damit weiterhin, welche Website gerade aufgerufen wird, egal wie sauber DNS-Ebene und Transportebene sonst verschlüsselt sind.

Illustration zu Encrypted Client Hello (ECH): Ein sichtbarer ClientHelloOuter mit dem gemeinsamen Decknamen ech.kernel-error.de schützt die verschlüsselten Hostnamen mehrerer Domains auf einem nginx-Server mit OpenSSL 4.0.

Encrypted Client Hello schließt genau diese Lücke, indem der eigentliche Servername in einen zweiten, verschlüsselten Client Hello wandert. Der folgende Beitrag beschreibt, wie ECH auf dieser Infrastruktur aktiviert wurde: mit OpenSSL 4.0 als Voraussetzung, nginx als Ports-Build und sechs Domains, die sich einen gemeinsamen Deckname teilen. Alle Befehle, Konfigurationsausschnitte und Testausgaben stammen aus dem produktiven Rollout und sind gegengeprüft, aktuell laufen alle sechs sauber verifiziert.

Was Encrypted Client Hello eigentlich verbirgt

Ein ECH-Verbindungsaufbau enthält zwei Client Hellos statt einem. Das äußere ist wie gewohnt unverschlüsselt, trägt aber nicht die echte Domain, sondern einen Deckname. Das innere steckt verschlüsselt im äußeren und enthält die tatsächlich angefragte Domain:

ClientHelloOuter: unverschlüsselt sichtbar, enthält public_name
ClientHelloInner: verschlüsselt, enthält die tatsächlich besuchte Domain

Der Server entschlüsselt das innere Client Hello mit einem Schlüssel, den er vorher über DNS veröffentlicht hat, wählt anhand der darin enthaltenen echten SNI den passenden vHost aus und antwortet als wäre nie ein Deckname im Spiel gewesen. Für den Browser läuft das komplett automatisch ab, sobald der Server ECH per DNS anbietet.

Voraussetzungen, bevor es losgeht

  • OpenSSL 4.0 oder neuer (ECH ist seit dem stabilen 4.0.0-Release vom 14. April 2026 Bestandteil, im Alpha-/Dev-Branch bereits ab 10./11. März 2026 verfügbar), alternativ BoringSSL
  • nginx 1.29.4 oder neuer, mit der Direktive ssl_ech_file (hier im Einsatz: 1.30.4)
  • eine DNSSEC-signierte Zone ist sinnvoll, aber keine normative ECH-Voraussetzung, dazu weiter unten mehr

Auch der erste Punkt ist keine Formalität. Das Lighttpd-Wiki führt ECH aktuell weiterhin als EXPERIMENTAL und nennt dieselbe Voraussetzung, OpenSSL 4.0 oder BoringSSL. ECH ist über die Server-Landschaft hinweg gesehen noch frisch, entsprechend lohnt vor dem eigenen Rollout ein Blick auf die aktuell installierte Softwareversion, nicht auf das, was vor einem halben Jahr noch galt.

Der Umstieg auf OpenSSL 4.0 als Ausgangspunkt

nginx läuft in dieser Jail als Ports-Build, nicht als Repo-Paket, weil das Paket Brotli und headers_more deaktiviert ausliefert. Damit lässt sich die verwendete OpenSSL-Hauptversion über die SSL-Flavor-Einstellung in /etc/make.conf steuern und nginx anschließend dagegen neu bauen:

jexec nginx pkg unlock -y nginx
# /etc/make.conf: DEFAULT_VERSIONS+=ssl=openssl40 (vorher openssl35)
jexec nginx make -C /usr/ports/www/nginx missing
jexec nginx make -C /usr/ports/www/nginx -DBATCH build
jexec nginx make -C /usr/ports/www/nginx -DBATCH deinstall install
jexec nginx pkg lock -y nginx
jexec nginx service nginx restart

Ein reload reicht an dieser Stelle ausdrücklich nicht. Die neuen Shared Libraries werden erst beim vollständigen Neustart der Worker-Prozesse eingebunden, ein reload behält die alten libssl-Bindings der laufenden Prozesse bei. Die neue Version zeigt sich danach direkt im Server-Build:

$ nginx -V
built with OpenSSL 4.0.1 9 Jun 2026

public_name ist die eigentliche Entscheidung

ssl_ech_file ist keine Bastellösung und kein Fork, sondern eine echte, in nginx-Mainline gemergte Direktive, sichtbar direkt im Quellcode unter src/http/modules/ngx_http_ssl_module.c und src/event/ngx_event_openssl.c. Technisch nutzt sie die OSSL_ECHSTORE-API, die mit OpenSSL 4.0 dazugekommen ist.

Der eigentlich entscheidende Wert ist public_name, der Name, der im unverschlüsselten ClientHelloOuter sichtbar bleibt. Ein Netzwerkbeobachter sieht ihn ohnehin, ECH hin oder her. Setzt man ihn auf die eigene, echte Domain, bringt die ganze Übung keinerlei Verschleierung: der Beobachter sieht exakt den Namen, den er auch ohne ECH gesehen hätte, nur mit einem zusätzlichen Handshake-Schritt drumherum.

Echten Schutz gibt es nur mit einem gemeinsamen public_name über mehrere unabhängige Domains hinweg, dem Anonymitätsset-Prinzip. Genau so macht es Cloudflare mit cloudflare-ech.com als Deckname für Millionen Kundendomains: wer nur sieht, dass jemand eine Verbindung zu cloudflare-ech.com aufbaut, weiß damit nichts über die konkret besuchte Seite unter den Millionen dahinter.

Ein gemeinsamer public_name reicht dafür allein aber noch nicht. Im ClientHelloOuter steht neben dem public_name auch die config_id unverschlüsselt, ein einzelnes Byte, das die verwendete ECHConfig identifiziert. Nutzt jede Domain einen eigenen ECH-Schlüssel, hat jede Domain automatisch auch eine eigene config_id, und ein Beobachter kann die Domains trotz identischem Deckname allein anhand dieses Bytes unterscheiden. RFC 9849 bringt es auf den Punkt: verwendet ein Server für verschiedene Servernamen unterschiedliche ECHConfig-Werte, hat jedes Anonymitätsset effektiv Größe 1, also gar keinen Effekt. Die Konsequenz für den eigenen Aufbau: alle teilnehmenden Domains müssen denselben Schlüssel und dieselbe ECHConfig nutzen, nicht nur denselben Deckname.

Diese eine nginx-Instanz hostet bereits rund zwanzig unabhängige Domains auf derselben IP, darunter dieser Blog, eine Therapiepraxis-Website, eine Fotogalerie, private Familienseiten, Webmail und ein URL-Shortener. Die Voraussetzung für ein eigenes, kleines Anonymitätsset war also schon da, es musste nur genutzt werden.

Umgesetzt wurde ein dedizierter Deckname, ech.kernel-error.de, ohne eigenen Inhalt. Sechs Domains teilen sich einen gemeinsamen ECH-Schlüssel und damit dieselbe ECHConfig, denselben public_name und dieselbe config_id: www.kernel-error.de, die Apex-Domain kernel-error.de, webmail.kernel-error.de, bilder.kernel-error.de und kernel-error.org als eigene, ebenfalls DNSSEC-signierte Zone. Die TLS-Zertifikats-Schlüssel der einzelnen Domains bleiben davon unberührt getrennt, das ist ein anderes Schlüsselpaar als der ECH-HPKE-Schlüssel und für die Anonymitätsset-Eigenschaft ohne Belang. stammbaum.kernel-error.de, ein CNAME auf www, erbt die ECH-Konfiguration automatisch per DNS-CNAME-Chasing, ganz ohne eigenen Eintrag.

Schlüssel erzeugen

Für die Schlüsselerzeugung braucht es das Port-OpenSSL unter /usr/local/bin/openssl, nicht das Base-System-OpenSSL von FreeBSD, dem fehlt der ech-Subcommand komplett:

openssl ech -public_name ech.kernel-error.de -out /usr/local/etc/nginx/ssl/ech/shared.kernel-error.pem

Das Ergebnis ist eine PEM-Datei mit zwei Blöcken, dem privaten Schlüssel und der öffentlichen ECH-Konfiguration:

-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
-----BEGIN ECHCONFIG-----
AEb+DQBC8AAgACDKMT4bTYtGnUXKvOkeuYEWDaByMjijSRINkLQxIEHQQQAEAAEAAQATZWNoLmtlcm5lbC1lcnJvci5kZQAA
-----END ECHCONFIG-----

Der Base64-Block zwischen den ECHCONFIG-Markern ist exakt der Wert, der später als ech=-Parameter in den DNS-Eintrag wandert. Das PEM-Format selbst folgt keinem nginx- oder OpenSSL-eigenen Standard, sondern ist mittlerweile als RFC 9934 festgeschrieben, hervorgegangen aus dem Internet-Draft draft-farrell-tls-pemesni, den auch das DEfO-Projekt in seinen ech-dev-utils dokumentiert.

Ein gemeinsamer Schlüsselspeicher für alle Domains

Bei mehreren Domains mit gemeinsamem public_name gehört ssl_ech_file auf die http{}-Ebene in der nginx.conf, noch vor die vHost-Includes, nicht in die einzelnen vHost-Dateien:

ssl_ech_file /usr/local/etc/nginx/ssl/ech/shared.kernel-error.pem;

Der Grund liegt in der Reihenfolge des Handshakes. Die ECH-Entschlüsselung passiert, bevor nginx anhand der noch verschlüsselten echten SNI überhaupt den passenden vHost auswählen kann. In diesem Moment entscheiden die äußere SNI, also der gemeinsame public_name, und die vom Client mitgeschickte config_id, welcher Schlüssel überhaupt zum Entschlüsseln probiert wird. Ein Store auf http{}-Ebene stellt sicher, dass genau dieser eine, gemeinsame Schlüssel unabhängig vom initial per SNI getroffenen Serverblock verfügbar ist, egal welche der sechs Domains die Verbindung eigentlich betrifft.

Ein eigenes Zertifikat für den Deckname

Der Deckname selbst braucht ein eigenes Zertifikat mit ausschließlich diesem einen SAN-Eintrag, kein Wildcard-Zertifikat, das nebenbei auch die echten Domains auflistet. Sonst verrät schon das äußere Zertifikat die Domain-Familie, selbst wenn die konkrete Domain im Handshake verschlüsselt bleibt:

certbot certonly --nginx -d ech.kernel-error.de --key-type ecdsa
X509v3 Subject Alternative Name:
    DNS:ech.kernel-error.de

Der nginx-vHost für den Deckname bleibt inhaltsleer und erbt den Schlüsselspeicher automatisch von der http{}-Ebene:

server {
    listen      [::]:443 ssl;
    http2 on;
    server_name ech.kernel-error.de;
    ssl_certificate     /usr/local/etc/letsencrypt/live/ech.kernel-error.de/fullchain.pem;
    ssl_certificate_key /usr/local/etc/letsencrypt/live/ech.kernel-error.de/privkey.pem;
    include              /usr/local/etc/nginx/tls-default.conf;
    location / { return 204; }
}

Der DNS-Eintrag

Die ECH-Konfiguration wird nicht als eigener Record-Typ verteilt, sondern als zusätzlicher ech=-SvcParam an der ohnehin schon vorhandenen HTTPS-Resource-Record angehängt, und zwar identisch auf allen sechs teilnehmenden Domains. Der Bootstrap-Mechanismus dafür steht in RFC 9848.

www  IN HTTPS  1 www.kernel-error.de. ( alpn="h3,h2" ipv4hint=148.251.30.200
     ipv6hint=2a01:4f8:262:4716::443
     ech="AEb+DQBC8AAgACDKMT4bTYtGnUXKvOkeuYEWDaByMjijSRINkLQxIEHQQQAEAAEAAQATZWNoLmtlcm5lbC1lcnJvci5kZQAA" )

Eine DNSSEC-signierte Zone ist dafür nicht normativ vorgeschrieben, aber sinnvoll: ohne Signatur kann ein Man in the Middle den ech=-Parameter einfach aus der Antwort entfernen, ein klassischer Downgrade auf Klartext-SNI, den ein validierender Resolver mit DNSSEC erkennt. Vor jeder Form von Downgrade schützt das trotzdem nicht: ein Angreifer kann die gesamte HTTPS/SVCB-Auflösung unterdrücken statt sie zu manipulieren, und gegen dieses Denial hilft DNSSEC allein nicht.

Verifikation nach dem Rollout

Der naheliegende erste Test ist openssl s_client mit dem passenden ECH-Config-List-Wert. Ohne ein gesetztes CA-Bundle lassen sich ein echter ECH-Fehler und ein simpler Zertifikatsfehler dabei nicht unterscheiden, -CAfile ist deshalb Pflicht, nicht optional:

openssl s_client -connect 148.251.30.200:443 -servername www.kernel-error.de -ech_config_list "AEb+DQBC8AAgACDKMT4bTYtGnUXKvOkeuYEWDaByMjijSRINkLQxIEHQQQAEAAEAAQATZWNoLmtlcm5lbC1lcnJvci5kZQAA" -CAfile /etc/ssl/cert.pem
Verify return code: 0 (ok)
ECH: success: 1
ECH: inner: www.kernel-error.de
ECH: outer: ech.kernel-error.de

Derselbe Test läuft identisch, mit demselben Base64-Wert und demselben Erfolg, auch für kernel-error.de, webmail.kernel-error.de, bilder.kernel-error.de und kernel-error.org, alle fünf gegen dieselbe ECHConfig verifiziert.

Für eine unabhängige Zweitmeinung von außen eignet sich das externe Tool echcheck. Es prüft nicht nur, ob der Handshake gelingt, sondern auch DNS-Veröffentlichung, verwendete Kryptografie, das innere Zertifikat und die Retry-Konfiguration einzeln:

ECHConfig aus dem DNS: ✓
finale ECH-Version 0xfe0d: ✓
X25519, HKDF-SHA256, AES-128-GCM: ✓
öffentlicher Name ech.kernel-error.de: ✓
echter ECH-Handshake: Accepted (TLS 1.3): ✓
gültiges inneres Zertifikat: ✓
gültige Retry-Konfiguration, Wiederholung erfolgreich: ✓
SNI Leakage: ✓ (Einzel-SAN-Zertifikat für den Deckname)
Certificate (outer): ✓ (Einzel-SAN-Zertifikat für den Deckname)

In allen neun geprüften Kategorien kommt ein Haken zurück, auch bei den beiden Punkten, die erst nach dem Einzel-SAN-Zertifikat für den Deckname sauber wurden. Ein einmaliger CLI-Test sagt aber nichts darüber, ob echte Clients die Funktion später auch tatsächlich nutzen. Dafür lohnt sich ein erweitertes Log-Format, das den ECH-Status jeder einzelnen Verbindung mitschreibt:

log_format tls_extended
  '$remote_addr - $remote_user [$time_local] '
  '"$request" $status $body_bytes_sent '
  '"$http_referer" "$http_user_agent" '
  'proto=$ssl_protocol cipher=$ssl_cipher kx_curve=$ssl_curve '
  'alpn=$ssl_alpn_protocol reused=$ssl_session_reused sni=$ssl_server_name '
  'ech_status=$ssl_ech_status ech_outer_sni=$ssl_ech_outer_server_name';

Im laufenden Traffic zeigen sich damit bereits echte ech_status=SUCCESS-Verbindungen, neben NOT_TRIED für Clients ohne ECH-Unterstützung und GREASE für Clients, die absichtlich eine leere ECH-Erweiterung mitschicken, um Middleboxen an das Feld zu gewöhnen. Genau dieser Log-Eintrag ist der eigentliche Erfolgsnachweis, nicht der einzelne CLI-Test.

Während dieser TLS-Arbeiten am selben Server ist außerdem noch ein kleinerer Nebenfund aufgetaucht: ein externer TLS-Scan meldete, dass der Server unter TLS 1.2 weiterhin SHA-224 als Hash-Funktion für die Signatur beim Schlüsselaustausch akzeptierte, obwohl die konfigurierten Cipher-Suiten ohnehin ausschließlich ECDSA nutzen. RFC 9847 stuft SHA-224 in der TLS-HashAlgorithm-Registry inzwischen als „Discouraged“ ein, während SHA-256, SHA-384 und SHA-512 dort als „Recommended“ geführt werden. Ein guter Grund, es trotzdem weiter anzubieten, findet sich aber nicht, wenn SHA-256 und höher ohnehin zur Verfügung stehen. Unter TLS 1.3 stellt sich die Frage gar nicht erst, dort ist für SHA-224 kein Signature Scheme registriert, die Hash-Funktion lässt sich strukturell nicht wählen.

Cipher-Suite und Signaturalgorithmus fürs Handshake sind zwei getrennte Stellschrauben, eine sauber konfigurierte Cipher-Suite sagt nichts über die andere aus. Ohne explizite Vorgabe verwendet OpenSSL seine Default-Liste der SignatureAlgorithms, und die enthält SHA-224 weiterhin aus Kompatibilitätsgründen:

openssl s_client -connect 148.251.30.200:443 -servername www.kernel-error.de -tls1_2 -sigalgs "ECDSA+SHA224"
Peer signing digest: SHA224
New, TLSv1.2, Cipher is ECDHE-ECDSA-CHACHA20-POLY1305

Der Fix ist eine Zeile in der gemeinsamen TLS-Konfigurationsdatei, damit gilt sie automatisch für alle vHosts, nur ECDSA-Varianten gelistet, passend zum bestehenden reinen ECDSA-Cipher-Setup:

ssl_conf_command SignatureAlgorithms ECDSA+SHA384:ECDSA+SHA256:ECDSA+SHA512;

Auch hier reichte ein reload nicht, erst ein vollständiger Neustart übernahm die geänderte Liste, aus demselben Grund wie beim OpenSSL-Umstieg weiter oben. Danach lehnt der Server SHA-224 ab, SHA-256 funktioniert unverändert:

SHA224: error:...:SSL routines:ssl3_read_bytes:tls alert handshake failure:...
SHA256: Peer signing digest: SHA256, New, TLSv1.2, Cipher is ECDHE-ECDSA-CHACHA20-POLY1305

Fazit

OpenSSL 4.0 war die technische Voraussetzung für diesen Umstieg, die eigentliche Entscheidung fiel aber woanders. Ein ECH-Setup mit dem eigenen Domainnamen als public_name wäre in wenigen Minuten fertig gewesen und hätte am Ende nichts verschleiert. Der Wert liegt allein in einem gemeinsamen Deckname über mehrere unabhängige Domains, und den gab es hier schon, bevor überhaupt eine Zeile Konfiguration geschrieben wurde.

Firefox unterstützt ECH seit Version 118, standardmäßig aktiv ist es seit Version 119, jeweils automatisch sobald ein Server es per DNS anbietet. Bei Chromium-basierten Browsern lässt sich keine ebenso feste Versionsgrenze nennen, der Rollout läuft dort gestaffelt über Channel und Policy. Der Nutzer muss in beiden Fällen nichts einstellen, die Aushandlung läuft beim ersten Verbindungsaufbau im Hintergrund. Wie schnell sich diese Versionsstände weiterentwickeln, lässt sich hier nicht dauerhaft verlässlich festschreiben, ein aktueller Blick in die jeweilige Browser-Dokumentation lohnt bei Zweifeln also immer.

Wer selbst mehrere unabhängige Domains auf derselben IP betreibt, hat die Grundvoraussetzung für ein eigenes Anonymitätsset damit häufig schon, ganz ohne Cloudflare oder einen anderen großen Anbieter dazwischen.

Siehe auch

Bei Fragen zum Setup, zur Wahl des Decknamens oder zum eigenen Anonymitätsset dürft ihr mich sehr gerne fragen.

sipgate unter Linux: ein Softphone, das Kontakte und Kalender aus der eigenen Nextcloud kennt

Vor zwölf Jahren habe ich hier beschrieben, wie der Familienkalender meiner Frau in die ownCloud gewandert ist. Vorher gab es ein Büchlein in ihrer Tasche und einen großen Kalender an der Wand, und beide waren selten einer Meinung. Seitdem liegen Termine und Kontakte unter eigener Kontrolle, und alles Mögliche greift darauf zu. Nur ein Gerät hat es nie getan: das Telefon am Schreibtisch.

Zwei Karten nebeneinander: links ein Anruf als nackte Rufnummer ohne Adressbuch, rechts derselbe Anruf mit aufgelöstem Namen aus der eigenen Nextcloud.

Das hat mich lange nicht gestört, bis ich es einmal ausprobiert habe. Ein Anruf kommt rein, auf dem Bildschirm steht eine nackte Rufnummer, und ich sitze vor einem Rechner, auf dem 144 Kontakte mit genau dieser Nummer liegen. Das ist albern.

Mein sipgate-Konto ist alt. Wie alt genau, wusste ich selbst nicht mehr. In diesem Blog taucht sipgate erstmals im August 2009 auf, und 2015 habe ich beim Abschalten meiner alten 01801-Nummer geschrieben, ich hätte sie „vor ? 13 ? Jahren“ bekommen. Die Fragezeichen waren schon damals meine. Also habe ich einfach den Support gefragt, ausdrücklich nur aus Neugier. Die Antwort kam am nächsten Morgen: angemeldet am 30. April 2005. Da sipgate 2004 mit den Basis-Accounts gestartet ist, bin ich also aus dem ersten Jahr dabei, gut zwanzig Jahre. Dass sich jemand die Mühe macht, so eine reine Neugier-Frage überhaupt zu beantworten, finde ich bemerkenswert.

Was ich wollte, war jedenfalls nichts Exotisches: ein Softphone unter Linux, das an diesem Konto hängt und beim Klingeln in mein eigenes Adressbuch schaut statt in gar keines. Dazu am besten noch die Kalender, damit ich beim Telefonieren sehe, ob der Termin, über den gerade gesprochen wird, überhaupt frei ist.

Der Weg dahin war überraschend kurz, aber die naheliegenden Kandidaten führen alle in die Irre. Deshalb schreibe ich beides auf.

Warum der naheliegende Weg nicht funktioniert

Der erste Reflex ist, das Softphone des Anbieters zu nehmen. Bei sipgate steht dazu im eigenen Hilfecenter, dass es unter Linux nicht installiert, sondern nur ausgeführt wird, dass es kein 64 Bit kann, also weder auf x86_64 noch auf ARM läuft, und dass der Support in Kürze eingestellt wird und keine Updates mehr kommen. Das ist eine erfrischend ehrliche Ansage, hilft aber nicht weiter.

Der zweite Reflex heißt Linphone, und das ist die Falle, in der ich am längsten gesteckt habe. Linphone kann CardDAV wirklich, die Funktionen stecken vollständig in der Bibliothek liblinphone. Nur hat die Oberfläche der paketierten Version keinen einzigen Schalter dafür, dort gibt es bei den Adressbuchquellen ausschließlich LDAP. In die Oberfläche kam CardDAV erst mit Version 6.0, und die liegt in keinem einzigen Linux-Distributionsrepo. Ein Blick auf Repology zeigt für linphone-desktop gerade einmal AUR bei 6.1.2, alles andere hängt bei 4.x oder 5.x, Debian unstable bei 5.2.6. Der Hersteller selbst liefert für Linux ausschließlich AppImages aus, ohne veröffentlichte Prüfsumme und ohne eingebettete Update-Information. Wer sich nicht um Aktualisierungen kümmern will, ist damit an der falschen Adresse.

Blink fällt aus, weil es zwar ein ausgereifter SIP-Client ist, beim Adressbuch aber nur Google Contacts kennt. GNOME Calls wäre technisch ein Weg, ist aber für Telefone gebaut und als Desktop-Softphone dünn.

Nebenbei: die Frage wird gestellt. Im Nextcloud-Forum steht ein Thread vom August 2025 mit genau diesem Wunsch, und der Fragesteller verweist darin auf einen älteren Thread zum selben Thema, der ohne Ergebnis blieb. Die Antworten waren eine kommerzielle Lösung und ein Umweg über die FRITZ!Box. Was ich jetzt benutze, kam in keinem der beiden vor.

Die Kette, die tatsächlich funktioniert

Der Trick besteht darin, dass das Softphone gar nicht mit der Nextcloud sprechen muss. Unter Linux gibt es dafür längst eine Zwischenschicht, und die heißt evolution-data-server. Sie ist auf so gut wie jedem Desktop installiert, weil Kalender- und Kontaktanwendungen darauf aufsetzen. Und sie spricht CalDAV und CardDAV von Haus aus.

Nextcloud
  |
  +-- GNOME Online Accounts     Provider owncloud, CalDAV und CardDAV
       |
       +-- evolution-data-server
            |
            +-- Softphone (GOnnect, Adressbuchquelle "EDS")

Das Softphone ist damit austauschbar, und die Zugangsdaten der Cloud liegen genau an einer Stelle statt in jeder Anwendung noch einmal. Als Softphone nehme ich GOnnect von der GONICUS GmbH, einem Open-Source-UC-Client, den es als Flatpak auf Flathub gibt. Er kann als Adressbuchquellen LDAP, CardDAV, CSV und eben evolution-data-server.

Dass er über Flathub kommt, war für mich das Ausschlusskriterium gegen alles andere. Ein AppImage müsste ich von Hand nachziehen, ein Flatpak läuft mit flatpak update einfach mit.

Schritt 1: Die Nextcloud in die Online-Konten hängen

Unter Linux Mint findest du das unter „Online-Konten“, dahinter steckt gnome-online-accounts-gtk. Dort legst du ein Konto vom Typ Nextcloud an, trägst die Adresse deiner Instanz, deinen Benutzernamen und ein App-Passwort ein und hakst Kalender und Kontakte an.

Der Dialog Online-Konten unter Linux Mint mit einem eingerichteten Nextcloud-Konto, bei dem Kalender, Kontakte und Dateien eingeschaltet sind.
Ein Konto vom Typ Nextcloud in den Online-Konten, Kalender und Kontakte angehakt. Ab hier kennt der ganze Rechner die Daten, nicht nur ein Programm.

Nimm dafür wirklich ein App-Passwort aus den Sicherheitseinstellungen deiner Nextcloud und nicht dein normales. Falls du später den Zugriff eines einzelnen Rechners zurückziehen willst, geht das damit mit einem Klick, ohne dass du überall sonst neue Zugangsdaten eintragen musst.

Wenn es geklappt hat, sieht die Konfiguration hinterher so aus:

[Account account_1735125820_0]
Provider=owncloud
Uri=https://cloud.example.org/remote.php/webdav
CalendarEnabled=true
CalDavUri=https://cloud.example.org/remote.php/dav
ContactsEnabled=true
CardDavUri=https://cloud.example.org/remote.php/dav
FilesEnabled=true
AcceptSslErrors=false

Auf AcceptSslErrors=false lohnt ein Blick. Steht dort true, akzeptiert die Verbindung kaputte Zertifikate, und dann kannst du dir den ganzen Rest sparen.

Ab hier haben alle Programme auf dem Rechner Zugriff, die evolution-data-server nutzen. Das Softphone ist nur eines davon.

Schritt 2: GOnnect installieren

flatpak install flathub de.gonicus.gonnect

Das war der ganze Schritt. Rund 310 MB, und beim ersten Start liest GOnnect die Kontakte und Kalender aus evolution-data-server ein, ohne dass du irgendwo CardDAV konfigurieren müsstest. Bei mir standen im Protokoll direkt beim ersten Start:

gonnect.app.addressbook: Found 1 active configurations for address book plugin "EDS"
gonnect.app.feeder.EDSAddressBookFeeder: Loaded 144 contact(s) of source "Kontakte"

Ja, und die Kalender ebenfalls, inklusive der Familienkalender aus dem Beitrag von 2014. Die stehen dann rechts im Fenster, unter den Favoriten und neben der Anrufliste.

Das Hauptfenster von GOnnect mit der Anrufliste auf der linken Seite, den Favoriten rechts oben und den Terminen aus der Nextcloud rechts unten.
Links die Anrufliste, rechts die Favoriten und darunter die Termine aus dem Familienkalender. Namen, Nummern und Kontaktbilder sind unkenntlich gemacht.

Und die Suche oben im Fenster greift auf dasselbe Adressbuch zu. Ein paar Buchstaben genügen, dann steht der Treffer da, und darüber die Quelle, aus der er kommt: eds-contacts. Genau darum ging es. Kein Zwischenschritt, kein Export, keine zweite Kontaktverwaltung im Softphone.

Die Kontaktsuche in GOnnect zeigt einen gefundenen Kontakt, darüber steht die Quelle eds-contacts.
Ein paar Buchstaben genügen. Über dem Treffer steht die Quelle: eds-contacts, also das Adressbuch aus der eigenen Nextcloud.

Schritt 3: Das sipgate-Konto eintragen

Hier kommt die Eigenheit von GOnnect, an der man sich einmal stoßen muss: es gibt keinen Einrichtungsassistenten und keinen Einstellungsdialog für das SIP-Konto. Der Client ist dafür gebaut, in Firmen ausgerollt zu werden, und erwartet deshalb eine fertige Konfigurationsdatei. Die liegt hier:

~/.var/app/de.gonicus.gonnect/config/gonnect/99-user.conf

Unter der Haube arbeitet PJSIP, entsprechend sehen die Schlüssel aus. Das hier ist die vollständige Konfiguration, die bei mir mit einem sipgate-Basis-Konto läuft:

[account0]
userUri=sip:1234567e0@sipgate.de
registrarUri=sip:sipgate.de
proxies=sip:sip.sipgate.de:5061;transport=tls
auth=auth0
transport=tls
srtpUse=mandatory
srtpSecureSignaling=1
verifyServer=true
caListFile=/etc/ssl/certs/ca-certificates.crt
contactRewriteMethod=always-update

[auth0]
scheme=Digest
username=1234567e0
realm=sipgate.de
type=digest
data=HIER_DER_MD5_HASH

1234567e0 ist deine SIP-ID aus dem sipgate-Konto, nicht deine Rufnummer.

Wichtig ist die Zeile type=digest. Du kannst dort auch dein Passwort im Klartext hinterlegen, aber das musst du nicht. SIP authentifiziert sich per Digest, und der Hash dafür ist schlicht MD5(Benutzer:Realm:Passwort). Den rechnest du dir selbst aus:

printf '%s' '1234567e0:sipgate.de:DEIN_SIP_PASSWORT' | md5sum

Das Ergebnis kommt hinter data=. Danach setzt du die Datei noch auf chmod 600.

Damit steht dein Passwort nicht mehr wörtlich in einer Konfigurationsdatei. Ehrlich bleiben muss man trotzdem: der Hash ist für diesen Realm genauso viel wert wie das Passwort selbst, wer ihn hat, kann sich anmelden. Der Gewinn ist ein anderer. Falls du dieses Passwort irgendwo sonst auch verwendest, liegt es hier nicht lesbar herum.

Ein Detail, das mich beim Ändern der Datei erwischt hat: GOnnect muss dabei beendet sein. Der Client schreibt die Datei beim Beenden aus dem Speicher zurück und überschreibt deine Änderungen sonst kommentarlos.

Was der Desktop davon merkt

Zwei Dinge sind mir erst im Betrieb aufgefallen, und beide gehören zu der Sorte, die man nicht vermisst, solange man sie nicht kennt.

Wenn ein Anruf reinkommt oder du selbst einen startest, pausiert die laufende Medienwiedergabe von selbst. Das YouTube-Video im Firefox hält an, der Musikplayer ebenso, und nach dem Auflegen läuft beides weiter. Dahinter steckt MPRIS, die Schnittstelle, über die sich Medienplayer unter Linux fernsteuern lassen. Für mich war das der Moment, in dem sich das Ding nicht mehr nach Fremdkörper angefühlt hat, sondern nach Teil des Desktops.

Das zweite: sip:-Links auf Webseiten funktionieren. Ein Klick darauf öffnet die Anwendung und wählt sofort. Der Client trägt sich beim Installieren als Handler für dieses Schema ein, du musst dafür nichts konfigurieren.

Verschlüsselung, und warum eine Zeile davon die wichtigste ist

sipgate kann das seit über zwanzig Jahren. Heise hat am 1. Februar 2006 über „sipgate-Crypto“ berichtet, damals schon mit TLS für die Signalisierung und SRTP für die Sprache bis zum Festnetz-Gateway. Das Problem war seinerzeit, dass kaum ein Endgerät mitspielte, weshalb sipgate passende Hardware gleich mit anbot. Zwanzig Jahre später kann es jedes Gerät, und trotzdem steht es in kaum einer Anleitung.

Eingeschaltet ist es nämlich nicht von allein. Die Standardkonfiguration läuft über UDP und unverschlüsselt, du musst zwei Dinge selbst ändern: sip.sipgate.de als Proxy eintragen und die Signalisierung von UDP auf TLS umstellen.

Interessant ist, was danach passiert. In der sipgate-Dokumentation steht dieser Satz:

Bei verschlüsselter Signalisierung erfordern wir ebenfalls, dass die Sprachdaten verschlüsselt werden. Ist diese Einstellung falsch, so werden Anrufe mit Fehler „488 No Acceptable here“ abgewiesen.

Sobald die Signalisierung verschlüsselt ist, erzwingt sipgate also auch die Sprachverschlüsselung. Das ist die angenehmste Sorte Sicherheitsentscheidung, weil sie einem die Wahl abnimmt. srtpUse=mandatory ist damit nicht meine Strenge, sondern schlicht das, was die Gegenseite ohnehin verlangt. Ein optional würde dir nichts retten, der Anruf käme trotzdem nicht zustande, nur mit einer verwirrenderen Fehlermeldung.

Kleine Randbemerkung, weil es mich gefreut hat: gefunden habe ich diesen Satz nicht durch Klicken im Hilfecenter, sondern hier:

curl https://help.sipgate.de/llms-full.txt

Das ist das komplette Hilfecenter als eine Textdatei, rund 350 KB, gedacht als maschinenlesbare Fassung für Sprachmodelle. Zum Durchsuchen mit grep ist das erheblich angenehmer als jede Suchmaske, und es beantwortet Fragen, die im Menü drei Ebenen tief liegen. Ich habe so etwas seit einer Weile selbst auf diesem Blog liegen, und es ist schön zu sehen, dass es sich langsam herumspricht.

Die eigentlich wichtige Zeile ist eine andere, und die steht in keinem Beispiel, das ich gefunden habe:

verifyServer=true
caListFile=/etc/ssl/certs/ca-certificates.crt

Die Voreinstellung von PJSIP ist verifyServer=false. Übersetzt heißt das: die Verbindung wird verschlüsselt, aber es wird nicht geprüft, mit wem. Jedes beliebige Zertifikat wird angenommen. Gegen jemanden, der auf dem Weg sitzt und die Verbindung übernimmt, ist so eine Verschlüsselung wertlos, weil er einfach sein eigenes Zertifikat vorzeigt. Erst mit diesen zwei Zeilen wird geprüft, und zwar gegen den Zertifikatsspeicher deines Systems.

Nachmessen statt glauben

Dass die Registrierung mit code=200 klappt, sagt über die Verschlüsselung nichts. Also nachsehen. Der einfachste Test zeigt, wohin die Verbindung überhaupt geht:

ss -tnp | grep 5061
ESTAB  [2001:db8::23]:49705 -> [2001:ab7::18]:5061  users:(("gonnect",pid=48721))
ESTAB  [2001:db8::23]:38171 -> [2001:ab7::1a]:5061  users:(("gonnect",pid=48721))

Port 5061, und auf Port 5060 liegt nichts mehr, es läuft also kein Klartext-SIP nebenher. Meine eigene Adresse habe ich ersetzt, die Gegenstellen sind echt. Ein whois auf 2001:ab7::/36 nennt die netzquadrat GmbH, und sip.sipgate.de löst genau auf diese Adressen auf. Das Zertifikat dazu:

openssl s_client -connect sip.sipgate.de:5061 -servername sip.sipgate.de </dev/null | openssl x509 -noout -subject -issuer
subject=CN = sip.sipgate.de
issuer=C = US, O = DigiCert Inc, CN = GeoTrust TLS RSA CA G1

Wenn du schon dabei bist, kannst du auch gleich nachsehen, was die Gegenstelle überhaupt anbietet. Das geht wie bei jedem anderen TLS-Dienst:

nmap -Pn -p 5061 --script ssl-enum-ciphers sip.sipgate.de
TLSv1.2:
  TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (ecdh_x25519) - A
  TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (ecdh_x25519) - A
  ...
TLSv1.3:
  TLS_AKE_WITH_AES_256_GCM_SHA384 (ecdh_x25519) - A
  TLS_AKE_WITH_CHACHA20_POLY1305_SHA256 (ecdh_x25519) - A
  TLS_AKE_WITH_AES_128_GCM_SHA256 (ecdh_x25519) - A
cipher preference: server
least strength: A

Das Ergebnis ist erfreulich unaufgeregt. Es gibt nur TLS 1.2 und 1.3, die alten Versionen habe ich gegengeprüft und beide werden abgelehnt. Alles bewertet mit A, der Server bestimmt die Reihenfolge, und das Zertifikat ist RSA mit 4096 Bit. Ausgehandelt wird in der Praxis TLS 1.3 mit TLS_AES_256_GCM_SHA384 über X25519.

Ein Schönheitsfehler bleibt: unter TLS 1.2 stehen auch noch reine TLS_RSA_WITH_*-Suiten in der Liste, also solche ohne Forward Secrecy. Weil der Server die Reihenfolge vorgibt und ECDHE oben steht, bekommt sie in der Praxis niemand, sie sind der Rückfall für Uraltgeräte. Schöner wäre es trotzdem ohne.

Bleibt die Sprache. Während des Gesprächs zeigt GOnnect oben links ein grünes Schild, das ist die Verschlüsselungsanzeige.

Ein laufendes Gespräch in GOnnect, oben links ein grünes Schild als Anzeige für die Verschlüsselung.
Das grüne Schild oben links ist die Verschlüsselungsanzeige. Was dahinter wirklich passiert, steht erst im Protokoll.

Ein Symbol ist aber nur ein Symbol. Was dahinter passiert, protokolliert der Client erst, wenn du ihn darum bittest:

[logging]
level=6

Danach einmal telefonieren und ins Protokoll schauen. Was du sehen willst, sind zwei Dinge. Erstens die Aushandlung, in der beide Seiten RTP/SAVP sprechen statt RTP/AVP:

m=audio 4000 RTP/SAVP 96 97 98 99 3 0 8 9 100 102 103 101 104 105 106
a=crypto:1 AES_256_CM_HMAC_SHA1_80 inline:S3ybiZ92TCP4JuPe48V...
a=crypto:3 AES_CM_128_HMAC_SHA1_80 inline:r2RXvwQ31ewSR0xEvuZX...

Und zweitens die Bestätigung, welche der angebotenen Suiten die Gegenstelle genommen hat:

srtp0x...  SRTP started, keying=SDES, crypto=AES_256_CM_HMAC_SHA1_80
           SRTP status: Active  Crypto-suite: AES_256_CM_HMAC_SHA1_80

sipgate hat also die stärkste der vier angebotenen Varianten gewählt. Erst damit weiß ich, dass srtpUse=mandatory wirklich gegriffen hat und nicht still auf „aus“ stand. Nebenbei: mehr als diese vier hat der Client gar nicht im Angebot, alle mit AES im Counter-Modus und HMAC-SHA1. AES-GCM kennt diese PJSIP-Variante nicht, an der Stelle ist also nichts zu optimieren.

Und dann dreh den Loglevel wieder runter. In den Zeilen oben stehen die SRTP-Schlüssel im Klartext, die haben in einer Protokolldatei nichts verloren.

Was dabei nicht verschlüsselt ist

Hier muss ich die gute Laune etwas bremsen, denn „alles verschlüsselt“ wäre falsch.

Verschlüsselt ist die Strecke zwischen deinem Rechner und sipgate. Das ist viel wert, es schützt gegen dein WLAN, gegen fremde Netze und gegen deinen Provider, und zwar sowohl den Gesprächsinhalt als auch die Information, wen du wann anrufst.

Es endet aber bei sipgate. Rufst du ein Mobiltelefon an, läuft der Anruf ab dort durch das normale Telefonnetz, und dort gibt es keine Verschlüsselung. Dazu kommt, dass SDES als Schlüsselaustausch bedeutet, dass die Schlüssel im Signalisierungsweg stehen, den sipgate liest. Der ist zwar durch TLS gegen Dritte geschützt, aber sipgate kennt deine Schlüssel und könnte mithören. Etwas anderes steht auch gar nicht zur Wahl: ZRTP und DTLS-SRTP kommen in der gesamten sipgate-Dokumentation kein einziges Mal vor.

Echte Ende-zu-Ende-Verschlüsselung bekommst du nur zwischen zwei Clients, die direkt miteinander eines von beidem sprechen. Mit einem Anbieter dazwischen ist das, was hier läuft, die Obergrenze. Das ist kein Grund, es zu lassen, man sollte es nur nicht mit etwas verwechseln, das es nicht ist.

Kleinkram, der auffällt

Zwei Meldungen im Protokoll haben mich kurz beunruhigt und sind harmlos. Failed to connect to pipewire instance erscheint, weil das Flatpak den PulseAudio-Socket hat und auf pipewire-pulse zurückfällt. Die Geräte werden danach sauber gesetzt, der Ton funktioniert. Die Fehler des JitsiConnector betreffen die Videokonferenz-Anbindung und nicht das Telefonieren.

Was tatsächlich nicht geht: globale Tastenkürzel. Cinnamon bringt das Portal org.freedesktop.portal.GlobalShortcuts nicht mit, Anrufe per Tastendruck annehmen fällt damit aus. Alles andere, auch das Symbol im Systemabschnitt der Kontrollleiste, funktioniert.

Fazit

Der Teil, den ich für den schwierigen hielt, war der einfachste. Ich musste am Softphone überhaupt kein CardDAV einrichten, weil evolution-data-server das längst erledigt hatte. Ein Konto in den Online-Konten, und die Kontakte und Kalender stehen jedem Programm auf dem Rechner zur Verfügung.

Aufwendig war stattdessen, überhaupt einen Client zu finden, der unter Linux gepflegt wird und den man ohne AppImage-Gefummel aktuell hält. Dass ausgerechnet ein Client, der eigentlich für den Rollout in Firmen gedacht ist und deshalb keinen Einrichtungsdialog hat, für einen einzelnen Basis-Account die beste Wahl ist, hätte ich vorher nicht erwartet.

Und der Familienkalender, der 2014 in die ownCloud gewandert ist, steht jetzt neben dem Telefon. Nur zwölf Jahre später.

Siehe auch:

Falls ihr das nachbaut und irgendwo hängen bleibt, oder falls ihr einen besseren Weg kennt, dann dürft ihr mich sehr gerne fragen.

Keyoxide: den OpenPGP-Schlüssel an Online-Identitäten binden, vier grüne Haken und ein rotes Kreuz

Moin. Seit dem 24. Juli 2026 habe ich einen neuen GPG-Schlüssel. Ed25519, gültig bis 2031, der Hauptschlüssel zertifiziert nur noch und hat für Signieren, Verschlüsseln und Authentisieren je einen eigenen Unterschlüssel. Technisch ein hübsches Ding. Der alte Schlüssel bleibt bis Ende des Jahres gültig und hat den neuen gegengezeichnet, damit der Wechsel keine harte Kante bekommt.

OpenPGP-Schlüssel mit Keyoxide-Identity-Claims für kernel-error.de, kernel-error.com, GitHub und Matrix, vier verifizierte Claims und ein entfernter Claim

Und dann stand da die Frage, die eigentlich immer die wichtigere ist und trotzdem meistens hintenrunterfällt: Woher soll irgendwer wissen, dass dieser Schlüssel mir gehört? Nicht „wo finde ich den Schlüssel“, das ist gelöst. Sondern: Warum sollte jemand glauben, dass die Person, die diesen Schlüssel kontrolliert, dieselbe ist wie die hinter github.com/Kernel-Error, hinter dem Mastodon-Account und hinter dieser Domain?

Die alten Antworten funktionieren nicht mehr

Die klassische Antwort auf diese Frage heißt Web of Trust. Ich unterschreibe deinen Schlüssel, du unterschreibst meinen, irgendwann spannt sich ein Netz aus Signaturen über die Welt und jeder kann über ein paar Ecken einen Pfad zu jedem finden. Elegante Idee. Nur: Wann warst du zuletzt auf einer Keysigning-Party? Eben. Ich auch nicht.

Dazu kommt ein sehr praktisches Problem, über das kaum jemand redet: keys.openpgp.org, der Keyserver, den heute praktisch alle benutzen, veröffentlicht Fremdsignaturen grundsätzlich nicht. Zuverlässig überleben dort nur Selbstsignaturen, einzige Ausnahme sind eigens attestierte Fremdzertifizierungen, und die benutzt in der Praxis kaum jemand. Das ist als Schutz gegen Signatur-Flooding absolut nachvollziehbar, bedeutet aber eben auch: Das Web of Trust reist nicht mehr mit. Du kannst deinen Schlüssel von hundert Leuten unterschreiben lassen, auf dem meistgenutzten Keyserver sieht ihn trotzdem niemand mit diesen Unterschriften.

Die zweite klassische Antwort waren Zertifizierungsstellen für Personen. CAcert habe ich hier vor Jahren mal vorgestellt, und die Volksverschlüsselung wurde 2025 eingestellt. Übrig geblieben ist bei mir eine Zertifizierung über Governikus, die die Beglaubigung im Auftrag des BSI macht, also eine eID-basierte Bestätigung mit Level 3, die tatsächlich meinen bürgerlichen Namen an den Schlüssel bindet. Das ist eine der letzten echten Klarnamen-Bindungen, die man noch bekommt. Und ausgerechnet die ist eine Fremdsignatur, fliegt auf keys.openpgp.org also raus.

Was schon vorher da war, und was es eben nicht beweist

Beim Thema Erreichbarkeit war ich schon vorher gut aufgestellt. Der Schlüssel liegt im Web Key Directory, sowohl in der advanced als auch in der direct method. Er steht als OPENPGPKEY-Record in einer DNSSEC-signierten Zone, wie ich das hier vor Jahren schon einmal beschrieben habe. Er liegt auf keys.openpgp.org, keyserver.ubuntu.com und pgpkeys.eu. Er ist in der clearsigned security.txt referenziert, die zu meinem Umgang mit Vulnerability Reports gehört. Und er liegt schlicht als Datei zum Direktdownload.

Sieben Kanäle also. Alle beweisen exakt eine Sache: dass man den Schlüssel findet. Die Governikus-Zertifizierung beweist zusätzlich einen Namen. Aber keiner dieser Kanäle beweist, dass derselbe Schlüssel auch das GitHub-Konto kontrolliert, oder den Matrix-Account, oder die Domain als Identität und nicht nur als Hostingort einer Datei. Genau diese Lücke füllen Identity Claims, und genau dafür gibt es Keyoxide.

Der wichtigste Satz zuerst: Keyoxide hat keine Accounts

Das ist das größte Missverständnis, und ich schreibe es deshalb so deutlich wie möglich hin: Es gibt bei Keyoxide keine Registrierung, keinen Login, kein Profil zum Ausfüllen, keine API-Keys und keine Daten auf deren Seite. Man legt dort nichts an. Man kann dort gar nichts anlegen.

keyoxide.org ist ein zustandsloser Betrachter. Wenn jemand ein Profil öffnet, passiert Folgendes:

  • Der öffentliche Schlüssel wird geholt, per WKD oder von einem Keyserver.
  • Aus der Selbstsignatur werden die Notationen mit dem Namen proof@ariadne.id ausgelesen.
  • Jeder Claim wird live gegen den genannten Endpunkt aufgelöst, je nach Claim-Typ direkt im Browser oder über einen Proxy. Ein TXT-Record lässt sich aus einer Webseite heraus nun mal nicht selbst abfragen.
  • Grüne Haken werden gerendert, und danach vergisst der Dienst alles wieder.

Die Konsequenz daraus ist der eigentliche Clou: Der Schlüssel ist das Profil. Wenn keyoxide.org morgen offline geht, ist nichts kaputt. Die Claims stehen weiter im Schlüssel, die Proofs liegen weiter an ihren Endpunkten, und jeder kann die komplette Prüfung mit dig und curl von Hand nachvollziehen. Weiter unten zeige ich genau das.

Das ist das genaue Gegenmodell zu Keybase, wo die Verknüpfungen auf einem Server einer Firma lagen. Als die Firma verkauft wurde, war der Ärger groß und die Frage berechtigt, was mit den Identitäten passiert. Bei diesem Modell stellt sich die Frage nicht, weil der Betreiber nichts hält, was verloren gehen könnte. Die zugrundeliegende Spezifikation heißt Ariadne Identity, Keyoxide ist nur eine Implementierung davon.

Zwei Hälften, die aufeinander zeigen

Jeder Proof besteht aus zwei Aussagen, die sich gegenseitig referenzieren:

HälfteLiegt inSagt
Claimder Selbstsignatur des Schlüssels„Ich kontrolliere kernel-error.de“
Proofgenau diesem Endpunkt„Der Schlüssel mit dem Fingerprint 45FC… gehört mir“

Auf den ersten Blick sieht das zirkulär aus. Der Schlüssel sagt, ihm gehöre die Domain, und die Domain sagt, ihr gehöre der Schlüssel. Beweist das nicht einfach gar nichts? Doch, und zwar genau deshalb, weil es zirkulär ist: Nur wer beides kontrolliert, kann beide Hälften zur Deckung bringen.

Spielen wir es durch. Jemand klaut meinen geheimen Schlüssel. Er kann jetzt beliebige Claims hineinschreiben, zum Beispiel „ich kontrolliere example.org“. Aber er kann bei example.org keinen TXT-Record setzen, der auf meinen Fingerprint zeigt. Der Claim bleibt rot. Umgekehrt: Jemand übernimmt eine meiner Domains und setzt dort einen TXT-Record mit meinem Fingerprint. Schön, aber ohne den geheimen Schlüssel kann er den passenden Claim nicht in die Selbstsignatur schreiben. Auch das ergibt keinen grünen Haken. Ein Angreifer mit einer der beiden Hälften bekommt nichts. Er braucht beide.

Warum eine Notation und keine Signatur

Ein Claim ist technisch ein Notation-Subpacket in der Selbstsignatur einer User-ID. Eine Notation ist ein frei definierbares Schlüssel-Wert-Paar, das im Signaturpaket mitsigniert wird. Der Name ist bei Ariadne immer proof@ariadne.id, der Wert beschreibt den Endpunkt.

Und hier schließt sich der Kreis zu dem Keyserver-Problem von oben. Weil die Claims in der Selbstsignatur stehen und nicht in einer Fremdsignatur, kann kein Keyserver sie wegputzen. Meine Governikus-Zertifizierung ist auf keys.openpgp.org verschwunden, weil sie eine Fremdsignatur ist. Die Keyoxide-Claims sind da, weil ein Keyserver eine Selbstsignatur nicht entfernen kann, ohne den Schlüssel dabei zu zerstören. Das ist für mich das stärkste praktische Argument, Notationen zu sammeln statt Unterschriften.

Vier Claims, vier sehr unterschiedliche Qualitäten

Live sind am Ende vier Claims, alle ausschließlich auf der Namens-UID:

proof@ariadne.id=dns:kernel-error.de?type=TXT
proof@ariadne.id=dns:kernel-error.com?type=TXT
proof@ariadne.id=https://gist.github.com/Kernel-Error/b51b81489a0600908cf09aca13765b9c
proof@ariadne.id=matrix:u/kernel-error:kernel-error.com?org.keyoxide.r=dBfQZxCoGVmSTujfiv:matrix.org&org.keyoxide.e=q3zhGJTbPWZD5LjUnsnAI9p43YzHXglniNv_SL7dldQ
Keyoxide-Profil von Sebastian van de Meer mit vier verifizierten Identity Claims: kernel-error.com und kernel-error.de per DNS, dazu GitHub und Matrix, alle mit grünem Haken.
Vier Claims, vier grüne Haken. Geprüft wird das erst beim Aufruf, gespeichert ist bei Keyoxide davon nichts. Der Fingerprint darunter ist der einzige Anker.

Das öffentliche Profil dazu liegt unter keyoxide.org/45FCD081ADB54872EA5B06B9893DE0CDDE986DEB. Bevor jetzt jemand vier grüne Haken sieht und denkt, das seien vier gleichwertige Beweise: sind sie nicht, und das gehört ausgesprochen.

  • Die beiden DNS-Claims sind mit Abstand die stärksten. Sie liegen in meinen eigenen, DNSSEC-signierten Zonen auf meinen eigenen Nameservern. Wer den Record fälschen will, muss meine Domain- oder DNS-Verwaltung übernehmen. Unterwegs fälschen reicht nicht, sofern der Prüfer DNSSEC auch wirklich validiert.
  • Der Gist-Claim hängt daran, dass GitHub sein URL-Schema beibehält und den Gist nicht wegräumt.
  • Der Matrix-Claim hängt an matrix.org und daran, dass ein bestimmter Raum weiter existiert und die Nachricht nicht gelöscht wird.

Ein Claim ist nie stärker als der Endpunkt, auf den er zeigt. Vier grüne Haken nebeneinander suggerieren eine Gleichwertigkeit, die es nicht gibt.

Domain-Proof: ein TXT-Record am Apex

Der Proof für eine Domain ist ein TXT-Record. Bei mir in beiden Zonen:

kernel-error.de.   IN TXT  "openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB"
kernel-error.com.  IN TXT  "openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB"

Am Apex, nicht unter www, und das ist eine bewusste Entscheidung. Der Claim behauptet etwas über die Identität der Domain, nicht über einen Webserver-Hostnamen. www ist nur ein Host darunter. Keyoxide rendert den Claim außerdem als den Domainnamen, und kernel-error.de liest sich als Identität, während www.kernel-error.de sich als Webseite liest. Kleiner Unterschied, aber genau der Punkt, um den es hier geht.

Beide TLDs bekommen den Record, weil sie bei mir verschiedene Rollen haben. Die .com ist die Mail-Identität mit WKD und DANE, die .de ist die Webpräsenz mit der security.txt. Beides bin ich, also gehört beides an den Schlüssel gebunden.

Beide Zonen sind DNSSEC-signiert und laufen mit inline-signing. Für so eine Bearbeitung heißt das: einfrieren, editieren, prüfen, wieder auftauen. Ein rndc reload ist hier der falsche Befehl.

rndc freeze kernel-error.de
# Zonendatei editieren, Serial hochzählen
named-checkzone kernel-error.de /path/to/kernel-error.de.zone
rndc thaw kernel-error.de

Angenehm unspektakulär war dabei, dass mehrere TXT-Records am selben Namen problemlos nebeneinander leben. Der neue Record ist einfach neben den SPF-Record und zwei Verifizierungs-Records gerutscht, ohne dass ich irgendetwas anfassen musste.

Einen Bluesky-Claim habe ich bewusst weggelassen. Meine did:plc:... hängt ohnehin schon über einen DNS-TXT-Record und /.well-known/atproto-did an der Domain. Wer den Domain-Claim prüft, hat Bluesky damit transitiv mit erledigt. Noch ein Claim mehr wäre nur mehr Fläche ohne mehr Aussage.

GitHub: ein öffentlicher Gist namens proof.md

Für GitHub braucht es einen öffentlichen Gist. Die Datei darin muss proof.md heißen, der Inhalt ist eine einzige Zeile:

openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB

Das geht auch ohne Browser, mit der GitHub-CLI:

gh gist create --public --desc "OpenPGP identity proof (Keyoxide / Ariadne)" proof.md

Eine Abgrenzung, über die regelmäßig jemand stolpert, deshalb explizit: Einen GPG-Schlüssel im GitHub-Profil zu hinterlegen, damit signierte Commits als „Verified“ angezeigt werden, ist eine völlig andere Sache. Anderer Mechanismus, anderer Zweck, und mit dem Keyoxide-Proof hat das nichts zu tun. Der hinterlegte Schlüssel wird von GitHub automatisch für die Commit-Verifikation benutzt. Einen Schalter „diesen Schlüssel als Signing Key verwenden“ gibt es für GPG-Schlüssel übrigens gar nicht, den gibt es nur für SSH-Schlüssel.

Matrix: der Proof ist eine Nachricht

Bei Matrix hat der Proof wieder eine ganz andere Form. Er ist weder eine Datei noch ein DNS-Record, sondern eine Nachricht in einem öffentlichen Raum. Man postet in #doipver:matrix.org schlicht:

openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB

Und zeigt dann in der Notation auf genau diese Nachricht. Beim Formatieren gibt es drei Kleinigkeiten, die man garantiert einmal falsch macht: Beim User wird das führende @ weggelassen, bei der Raum-ID das führende ! und bei der Event-ID das führende $. Aus @kernel-error:kernel-error.com wird also u/kernel-error:kernel-error.com, aus !dBfQZxCoGVmSTujfiv:matrix.org wird der nackte Rest, und genauso bei der Event-ID.

Wichtig ist außerdem, dass der Raum unverschlüsselt ist. Eine verschlüsselte Nachricht kann ein Prüfer nicht lesen, damit wäre der Proof wertlos. #doipver ist genau dafür da und entsprechend offen.

Der ganze Vorgang lässt sich komplett über die Client-Server-API erledigen, also POST /_matrix/client/v3/join/... und danach PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}. Die Antwort auf das Senden enthält direkt die event_id, also genau das, was die Notation braucht. Das ist elegant, wenn man es scripten will.

Ehrlicherweise muss man dazu aber sagen: Ein Access-Token gibt volle Kontrolle über den Account. Das aus der Hand zu geben, nur um eine einzige öffentliche Zeile abzusetzen, ist ein schlechtes Geschäft. Der Weg über Element von Hand kostet dreißig Sekunden: Nachricht posten, „Teilen“ beziehungsweise Permalink aufrufen, und Raum-ID sowie Event-ID direkt aus der matrix.to-URL ablesen. Wer trotzdem ein Token benutzt, sollte das Gerät danach abmelden.

Die Claims in den Schlüssel schreiben

Das eigentliche Eintragen passiert in gpg --edit-key. Sieht harmlos aus, hat aber gleich in Zeile zwei die erste Falle:

gpg --edit-key 0x893DE0CDDE986DEB
uid 1
notation
proof@ariadne.id=dns:kernel-error.de?type=TXT
save

Entfernen geht über notation und dann den gleichen Ausdruck mit einem führenden Minus, none löscht alle. Und weil man sich auf Anzeigen ungern verlässt, hier der Blick auf das, was wirklich im Schlüssel steht:

gpg --export 0x893DE0CDDE986DEB | gpg --list-packets | grep notation

Falle 1: uid 1 ist nicht optional

Ohne vorher eine UID auszuwählen schreibt gpg die Notation in jede UID. Bei mir also auch in die Foto-UID. Keyoxide prüft dann denselben Claim zweimal und zeigt ihn doppelt an. Die offizielle Dokumentation erwähnt das im Nebensatz, überlesen ist es schnell, und aufräumen darf man es anschließend von Hand.

Falle 2: jede Änderung schreibt die Selbstsignatur neu

Das ist der eigentliche Preis, und davor warnt einen niemand. Ein Claim mehr bedeutet eine neue Selbstsignatur, und damit ist jede veröffentlichte Kopie des Schlüssels veraltet. Konkret hieß das bei mir jedes Mal: neu hochladen zu drei Keyservern, zwei selbst gehostete Dateien überschreiben und noch eine DNSSEC-Zonenbearbeitung für den DANE-Record hinterher.

Wer drei Claims einzeln nacheinander setzt, macht diese Runde dreimal. Also: Claims sammeln und in einem Rutsch eintragen. Nebenbei wächst der Schlüssel auch spürbar. Die DANE-rdata ist im Lauf des Tages von 909 auf 1130 Byte gewachsen, die WKD-Datei von 12703 auf 12940 Byte. Nichts Dramatisches, aber bei einem DNS-Record schaut man da schon hin.

Falle 3: drei Caches, drei vorgetäuschte Fehlschläge an einem Nachmittag

Das ist der Teil, bei dem vermutlich jeder Leser nickt. An einem einzigen Nachmittag haben mir drei verschiedene Caches je einen kaputten Rollout vorgespielt. In allen drei Fällen war das Deployment in Ordnung und die Prüfung hat gelogen.

Der eigene Resolver. Nach dem Setzen des TXT-Records schlug die lokale Prüfung immer weiter fehl. Google, Cloudflare und Quad9 hatten den neuen Record sofort. Der veraltete Eintrag kam ausgerechnet von meinem eigenen DoT/DoH-Resolver, auf den systemd-resolved zeigt, und der hielt die alte Antwort noch etwa zwei Stunden fest. Das sieht exakt aus wie ein kaputtes Deployment und kostet echte Debugging-Zeit.

ssh root@ns1 "rndc flushname kernel-error.de resolver"
resolvectl flush-caches

Der View-Name am Ende ist bei rndc flushname laut Handbuch optional. In meinem Setup mit einer eigenen resolver-View ist er aber Pflicht, sonst passiert schlicht nichts Sichtbares. Die allgemeine Lehre daraus: Wer eigene DNS-Änderungen prüft, fragt immer einen autoritativen Server und einen öffentlichen Resolver, niemals nur den Systemresolver.

Ein HTTP-Cache vor keyserver.ubuntu.com. Der lieferte einen veralteten Schlüssel mit nur der Hälfte der Claims aus. Ein angehängter Cache-Busting-Parameter brachte sofort die korrekte Version, und ein erneuter Upload wurde mit "ignored" quittiert. Der Server war also die ganze Zeit aktuell, nur der Cache davor nicht.

Und die Keyserver selbst. Keyserver mischen Selbstsignaturen, sie ersetzen sie nicht. Nach einem Update trägt die Keyserver-Kopie die alte und die neue Selbstsignatur. Ein naives grep -c notation zählt dann fünf Notationen, während die selbst gehostete Kopie drei zeigt. gpg nimmt beim Import die neueste, praktisch ist das also harmlos, aber jedes Verifikationsskript lügt einen dabei an. Man prüft besser, was ein frischer Import auflöst, nicht was im rohen Blob steht.

Die Moral aus allen drei Fällen ist dieselbe: gegen eine autoritative Quelle prüfen und gegen eine cache-busted Anfrage, niemals nur gegen die eine bequeme Quelle, die gerade zur Hand ist.

Falle 4: die Gist-Raw-URL springt den Host

Ein kurzer Schreckmoment mit einfacher Ursache. Die Adresse https://gist.github.com/<user>/<id>/raw/proof.md antwortet mit einem 301 auf gist.githubusercontent.com. Ein curl ohne -L liefert damit gar nichts zurück, was auf den ersten Blick nach einem kaputten Proof aussieht. Ist es nicht, es fehlt nur ein Buchstabe am Kommando.

Falle 5: –print-dane-records gibt es nicht mehr

In GnuPG 2.4 ist --print-dane-records weg. Der Aufruf bricht mit einem Fehler ab und verweist auf --export-options export-dane. Nur reicht export-minimal allein nicht: Die Foto-UID bleibt drin und bläst einen DANE-Record von rund einem Kilobyte auf etwa zwölf Kilobyte auf. Die kompakte Variante braucht zusätzlich einen Export-Filter:

gpg --export-filter "keep-uid=mbox = kernel-error@kernel-error.com" --export-options export-dane,export-minimal --export 0x893DE0CDDE986DEB

Der fünfte Claim: Fediverse, und warum er nicht live ist

Jetzt der ehrliche Teil. Ein fünfter Claim war gebaut, ausgerollt, nie verifiziert und noch am selben Tag wieder zurückgebaut. Ich schreibe das hier hin, weil vier aufgeräumte grüne Haken nichts erzählen und dieser eine Fehlschlag mehr über die Technik verrät als der Rest zusammen.

Der Plan war ein Claim auf https://www.kernel-error.de/@kernel-error.de, also den ActivityPub-Actor dieses Blogs, mit dem Proof in den Profil-Metadaten. Beim WordPress-ActivityPub-Plugin werden die „Extra Fields“ als PropertyValue-Attachments an den Actor gehängt. Zwei Entscheidungen dabei waren richtig und bleiben es auch nach dem Rückbau.

Erstens: Die Claim-URL leitet im Browser um, liefert aber unter Accept: application/activity+json sauber den Actor aus. „Mach das doch mal im Browser auf“ ist bei einem maschinenlesbaren Endpunkt schlicht kein gültiger Test. Wer so prüft, verwirft eine funktionierende Adresse.

Zweitens: Die Platzierung war wichtiger als gedacht. Keyoxide akzeptiert den Proof in der Bio, in einem Beitrag oder in den Profil-Metadaten. Bei einem WordPress-Blog-Actor ist die Bio aber der Blog-Slogan, und der steht auf jeder einzelnen Seite der öffentlichen Webseite. Ein Fingerprint im Header, weil man die erstbeste dokumentierte Möglichkeit genommen hat. Die Profil-Metadaten sind genauso gültig und für Leser unsichtbar. Lohnt sich also, erst alle Optionen zu lesen und dann zu wählen.

Ein Implementierungsdetail mit echter Fallhöhe: Das Feld wurde mit $wpdb->insert eingetragen, ausdrücklich nicht mit wp_insert_post(). Letzteres feuert die ActivityPub-Hooks und erzeugt einen Update-Eintrag in der Outbox, der dann als Geisterbeitrag durch das Fediverse geht. Kontrolliert habe ich das über den Zeilenstand der ap_outbox vor und nach dem Eingriff: 130 zu 130, sowohl beim Anlegen als auch beim Löschen. Danach Caches leeren, und zwar zuerst Redis, dann FastCGI, sonst füllt sich der FastCGI-Cache sofort wieder aus den alten Redis-Daten. Wer mehr über das Plugin-Innenleben lesen mag, findet in meinem Beitrag zum eigenen ActivityPub-Plugin für Grav die Mechanik dahinter.

Und dann zeigte Keyoxide trotzdem ein rotes Kreuz.

Ein rotes Kreuz und eine sehr überzeugende falsche Fährte

Der Claim stand auf dem Profil als rotes Kreuz mit der Beschriftung [---], was so viel heißt wie „kein Service Provider hat gematcht“. Die anderen vier waren grün. Das Access-Log sah so aus:

OPTIONS /@kernel-error.de                     301
GET     /.well-known/nodeinfo                 200
GET     /wp-json/activitypub/1.0/nodeinfo/2.1  200
GET     /@kernel-error.de/api/config           404   (doipjs/2.1.0)

Die naheliegende Lesart ist falsch, und sie ist verführerisch. /api/config ist ein Owncast-Endpunkt. Man denkt sofort: Keyoxide hält meinen Claim für einen Owncast-Server, da ist die Provider-Erkennung kaputt. Stimmt aber nicht. Die beiden NodeInfo-Abfragen darüber stammen aus dem ActivityPub-Postprozessor von doipjs, ActivityPub hatte also gematcht. Die Owncast-Abfrage ist der Fallback, der danach läuft, weil der ActivityPub-Test bereits gescheitert war. Symptom, nicht Ursache.

Die Ursache steht in der ersten Zeile. WordPress beantwortet ein OPTIONS auf die hübsche Actor-URL mit einem 301 auf die Startseite. Und ein umgeleiteter CORS-Preflight ist in allen heute relevanten Browsern ein harter Fehler. Die Fetch-Spezifikation ist an der Stelle inzwischen weniger strikt formuliert, in der Praxis bricht der Browser trotzdem ab, und der Prüfer kommt nie an den Actor heran. Das Ärgerliche daran: Die CORS-Header waren die ganze Zeit korrekt gesetzt, Access-Control-Allow-Origin: * und der ganze Rest. Sie kamen nur mit Status 301, und damit sind sie wertlos.

Eine andere Claim-URL hilft nicht heraus. /@handle und /@handle/ antworten beide mit 301 auf OPTIONS, und die eigentliche Actor-ID /?author=0 beantwortet OPTIONS mit 405 und ganz ohne CORS-Header. Es musste also in den nginx, mit einer Location, die den Preflight selbst beantwortet:

location ~ ^/@[^/]+/?$ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin  "*"                                  always;
        add_header Access-Control-Allow-Methods "GET, OPTIONS"                        always;
        add_header Access-Control-Allow-Headers "*"                                   always;
        add_header Access-Control-Max-Age       86400                                 always;
        return 204;
    }
    try_files $uri $uri/ /index.php?$args;
}

Platziert als erste Regex-Location, damit sie vor den späteren gewinnt, und der Nicht-OPTIONS-Zweig spiegelt exakt das, was auch location / macht, damit normaler Verkehr unverändert durchläuft. Der Preflight ging damit von 301 auf 204.

Und der erste Fix reichte nicht, das Log hat es leise gesagt

Nach dem Reload kam sauber der 204. Aber das anschließende GET /@kernel-error.de tauchte im Access-Log überhaupt nicht auf, und doipjs marschierte direkt wieder zur Owncast-Abfrage weiter. Genau diese Abwesenheit ist die Fehlermeldung: Ein Preflight, der erfolgreich ist und auf den dann gar keine Anfrage folgt, bedeutet, dass der Browser die Antwort auf den Preflight abgelehnt hat.

Die Liste der erlaubten Header war schlicht geraten. Accept, Authorization, Content-Type klingt vernünftig, deckte aber offensichtlich nicht ab, was doipjs tatsächlich schickt. Die Erweiterung auf Access-Control-Allow-Headers: * war die zweite Hälfte des Fixes. Der Wildcard wirkt hier, weil die Anfrage ohne Credentials und ohne HTTP-Auth läuft. Bei einer Anfrage mit Credentials wäre der Stern wirkungslos, und Authorization deckt er ohnehin nie ab, den müsste man immer namentlich nennen. Oben steht schon die korrigierte Fassung.

Noch eine Warnung für alle, die so etwas nachbauen: Access-Control-Max-Age: 86400 heißt, dass der Browser die alte Preflight-Antwort einen Tag lang behält. Ein normales Neuladen leert den CORS-Preflight-Cache nicht. Also im privaten Fenster nachtesten, sonst jagt man ein Gespenst.

Drei Lehren daraus, die weit über Keyoxide hinaus gelten:

  • Richtige Header auf einem falschen Statuscode sind trotzdem kaputt. Ein 301 mit perfekten CORS-Headern scheitert genauso hart wie gar keine Header.
  • Ein Log liest man von oben nach unten, nicht von unten nach oben. Die verdächtigste Zeile, der 404, war die Folge. Der langweilige 301 zwei Zeilen darüber war der Fehler.
  • Eine Anfrage, die im Log fehlt, ist auch ein Befund. Die zweite Runde habe ich ausschließlich aus etwas diagnostiziert, das nicht passiert ist.

Ein Nebeneffekt, der mir erst hinterher aufging: Das war nie ein Keyoxide-Problem. Jeder Cross-Origin-Konsument meines ActivityPub-Actors, der dafür im Browser läuft und einen Preflight braucht, ist an diesem Redirect gescheitert, seit es die Adresse gibt, und es ist niemandem aufgefallen. Serverseitige Abrufer waren nie betroffen, die kennen kein CORS. Der nginx-Fix ist deshalb geblieben, auch nachdem der Claim wieder weg war. Er repariert einen echten Defekt, völlig unabhängig von Keyoxide.

Und trotzdem blieb es rot

Nach dem CORS-Fix zeigte das Log endlich das gewünschte Bild: OPTIONS ... 204, gefolgt von GET /@kernel-error.de 200 6915 "doipjs/2.1.0". Der Actor wurde geholt. Und der Claim blieb rot. Der Reihe nach ausgeschlossen habe ich dann:

  • Provider-Matching. Das Regex in activitypub.js ist ein Catch-All, und die NodeInfo-Abfragen im Log beweisen ohnehin, dass der zugehörige Postprozessor gelaufen ist.
  • Feldplatzierung. attachment.value ist eines der drei Felder, die doipjs durchsucht.
  • Inhalt und Schreibweise. Fingerprint einmal in Großbuchstaben, einmal in Kleinbuchstaben probiert. Beides ohne Treffer. Die anderen vier Claims benutzen alle Großbuchstaben und verifizieren einwandfrei, an der Schreibweise liegt es also nicht.

Bleibt ein Verdacht, den ich ausdrücklich als unbewiesen kennzeichne: Das hier ist ein WordPress-ActivityPub-Blog-Actor. NodeInfo meldet software: wordpress, und der preferredUsername lautet kernel-error.de, ist also ein Benutzername mit Punkten darin, den Mastodon so gar nicht erzeugen könnte. Das ist ein deutlich weniger befahrener Pfad durch doipjs als ein normales Mastodon-Profil. Weiterzukommen hätte bedeutet, fremdes JavaScript zu debuggen, für den fünften von fünf Claims.

Also habe ich aufgehört, und die Entscheidung gehört zur Geschichte dazu. Rückbau komplett: Notation aus dem Schlüssel entfernt, alle sieben Kanäle neu ausgerollt, das Profilfeld gelöscht, Caches geleert. Der Claim ist damit vollständig verschwunden, es bleibt also kein dauerhaftes rotes Kreuz stehen. Das ist übrigens eine erfreuliche Eigenschaft: Ein Claim, der nicht funktioniert, lässt sich sauber zurückziehen. Nur die Historie der Notation liegt für immer auf den Keyservern. Wer die rohen Schlüsseldaten auseinandernimmt, findet die alten Selbstsignaturen samt Claim also weiterhin. Im aktuellen Profil taucht er nicht mehr auf.

Zwei Stolpersteine aus dem Rückbau, die man kennen sollte:

  • Das Entfernen einer Notation braucht eine Bestätigung. Die Reihenfolge ist uid 1, dann notation, dann der Ausdruck mit führendem Minus, dann ein y, dann erst save. Ohne das y schluckt gpg das nachfolgende save als Antwort auf seine Rückfrage, und das Entfernen passiert stillschweigend gar nicht.
  • Keyserver behalten jede Generation von Selbstsignaturen. Nach dem Rückbau lagen auf keys.openpgp.org zehn Selbstsignaturen auf dieser UID, mit der Reihe 4, 5, 4, 3, 2, 0 Claims. Harmlos, weil gpg wie openpgp.js die neueste nimmt. Aber --export-options export-clean reduziert das nicht auf eine Selbstsignatur. Wer Notationen über einen so bereinigten Export zählt, zählt zu hoch und hält einen erfolgreichen Rückbau für gescheitert. Besser die Zeitstempel pro Signatur vergleichen.

Falle 6: ein langsamer Matrix-Raum sieht aus wie kaputte Föderation

Zum Schluss noch eine schöne Fehldiagnose. Der erste Versuch, #doipver:matrix.org beizutreten, starb mit ResponseNeverReceived. Das liest sich wie ein glasklarer Föderationsausfall, und ich habe entsprechend angefangen zu suchen. Nur: federationtester.matrix.org meldete FederationOK: true mit gültigen Zertifikaten über IPv4 und IPv6, und blankes TCP wie auch HTTPS vom Server nach matrix.org liefen einwandfrei.

#doipver ist einfach ein großer, stark föderierter Raum. Der Alias löst auf hunderte beteiligte Server auf, und ein make_join gegen einen einzelnen ausgelasteten Server dauert entsprechend. Geholfen haben zwei Dinge: mehrere Server zum Beitreten mitgeben und einen großzügigen Timeout setzen, 180 Sekunden waren genug. Der Parameter heißt in der aktuellen Spezifikation via, ich habe hier noch den älteren Namen server_name benutzt.

POST /_matrix/client/v3/join/%21dBfQZxCoGVmSTujfiv%3Amatrix.org
     ?server_name=matrix.org&server_name=privacyguides.org&server_name=monero.social

Die übertragbare Lehre, gerade für dieses Publikum: Bevor man aus einer einzigen gescheiterten Anfrage eine Föderationsstörung diagnostiziert, sollte man prüfen, ob die Anfrage nur langsam war. Am selben Tag tauchten übrigens noch zwei 429 Too Many Requests bei Schlüsselabfragen im Log auf, ebenfalls reine Ablenkung.

Selbst nachprüfen, ganz ohne die Webseite

Und hier kommt der Punkt, an dem das ganze Modell überzeugt. Du musst keyoxide.org nicht vertrauen, du kannst die komplette Prüfung selbst machen. Vier Schritte, gegen meinen Schlüssel:

# 1. Schlüssel holen, so wie es ein Fremder tun würde
gpg --auto-key-locate clear,wkd --locate-external-keys kernel-error@kernel-error.com

# 2. Claims aus der Selbstsignatur lesen
gpg --export 0x893DE0CDDE986DEB | gpg --list-packets | grep 'proof@ariadne.id'

# 3. Claims auflösen
dig +short TXT kernel-error.de   | grep openpgp4fpr
dig +short TXT kernel-error.com  | grep openpgp4fpr
curl -sSL https://gist.github.com/Kernel-Error/b51b81489a0600908cf09aca13765b9c/raw/proof.md
# Matrix: Nachricht im Client öffnen oder das Event per API lesen
#   Raum  !dBfQZxCoGVmSTujfiv:matrix.org
#   Event $q3zhGJTbPWZD5LjUnsnAI9p43YzHXglniNv_SL7dldQ

# 4. Gegen den Fingerprint aus Schritt 1 vergleichen, das ist die gesamte Prüfung

Das ist Keyoxide. Mehr macht die Seite auch nicht, sie macht es nur hübscher und schneller. Wenn du diese vier Schritte einmal von Hand durchgehst, sitzt das Argument „du musst der Webseite nicht vertrauen“ deutlich besser als jede Erklärung.

Was das beweist, und was eben nicht

Damit hier niemand mit falschen Erwartungen rausgeht, die ehrlichen Grenzen:

  • Claims sind praktisch dauerhaft. Sie stehen in der Selbstsignatur, die auf Keyserver hochgeladen wird, und keys.openpgp.org kennt kein Zurücknehmen. Man sollte also nur Identitäten claimen, die man dauerhaft und öffentlich an den Schlüssel gebunden haben möchte. Genau deshalb habe ich Telegram und Threema bewusst weggelassen.
  • Xing und LinkedIn habe ich übersprungen, es gibt dort keinen brauchbaren Proof-Mechanismus. Eine Klarnamen-Bindung existiert bei mir ohnehin in stärkerer Form über die eID-Zertifizierung. Telefonnummern, SIP und Postanschrift sind nicht verifizierbar und ohnehin eine schlechte Idee.
  • Das beweist Kontrolle, nicht Identität. Ein grüner Haken sagt: Dieselbe Instanz kontrolliert diesen Schlüssel und dieses Konto. Er sagt nicht, wer diese Instanz ist. Dafür braucht es weiterhin so etwas wie eine eID-Zertifizierung oder ein persönliches Treffen. Das ist die ehrliche Grenze des ganzen Ansatzes, und sie zu überverkaufen wäre falsch.
  • Die Prüfung ist nur so stark wie der schwächste Endpunkt. Eine DNSSEC-signierte Zone auf eigenen Nameservern ist stark. Ein Gist auf einer Plattform, die morgen ihr URL-Schema ändern kann, ist es nicht.
  • Keyoxide selbst ist eine Bequemlichkeitsschicht. Verschwindet Keyoxide, funktionieren die Claims weiter. Verschwindet GitHub, ist genau dieser eine Claim tot.

Nachtrag: der neue Schlüssel hatte noch ein ganz anderes Problem

Beim Durchleuchten des Setups ist mir nebenbei ein echter Defekt am funkelnagelneuen Schlüssel aufgefallen: Er hatte überhaupt keine Algorithmus-Präferenzen. Die Folge war messbar und wirklich unangenehm. Ein Dritter, der an den alten Schlüssel verschlüsselt hat, bekam AES256. Beim neuen Schlüssel wurde daraus stillschweigend AES128. Der neue, modernere Schlüssel wurde also schwächer adressiert als der, den er ablöst. Das ist eine eigene Geschichte und die erzähle ich in einem eigenen Beitrag, denn der Unterschied zwischen „mein Schlüssel ist modern“ und „mein Schlüssel ist korrekt konfiguriert“ verdient mehr als eine Randnotiz.

Fazit

Das Web of Trust ist als Alltagsmechanismus tot, die Zertifizierungsstellen für Privatpersonen sind weitgehend weg, und trotzdem bleibt die Frage bestehen, wem ein Schlüssel gehört. Identity Claims sind darauf eine überraschend saubere Antwort: kein Account, kein Anbieter, der etwas hält, keine Abhängigkeit von einer Firma, die morgen verkauft wird. Nur zwei Aussagen, die aufeinander zeigen, und ein Schlüssel, der sein Profil selbst ist.

Der Aufwand ist überschaubar, wenn man die Claims sammelt und in einem Rutsch einträgt. Die Zeit geht nicht für Keyoxide drauf, sondern für die eigenen Caches und für einen alten Redirect, den nie jemand hinterfragt hat. So gesehen war der gescheiterte fünfte Claim der nützlichste von allen.

Siehe auch

Wie immer gilt: Wenn etwas unklar ist, wenn ich mich irgendwo irre oder wenn jemand doch noch weiß, warum ein WordPress-ActivityPub-Actor bei doipjs durchfällt, dann dürft ihr mich sehr gerne fragen.

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.

Tiered Storage live: Wie ein ZFS special vdev den HDD-Flaschenhals an der Wurzel packt

Ein einzelner ZFS-Pool aus zwei 7200-rpm-Platten war durch Metadaten-Random-I/O ausgebremst. Lösung ganz ohne Neuaufbau: die vorhandenen SSDs zu einem gespiegelten special vdev für Metadaten plus gespiegeltem SLOG umgebaut, zwei zpool add-Befehle im laufenden Betrieb. Resultat: Metadaten-Leselatenz von rund 46 ms auf rund 455 µs, also etwa Faktor hundert, bei voll erhaltener Verschlüsselung und Redundanz.

Drehende Platten sind ein ehrliches Stück Technik. Sie speichern viele Terabyte für wenig Geld und liefern bei sequenziellem Zugriff ordentlichen Durchsatz. Ihre Achillesferse ist der zufällige Zugriff auf viele kleine Blöcke, denn jede Kopfbewegung kostet Latenz im zweistelligen Millisekundenbereich aus Seek und Rotationswartezeit. Und genau dieses ungünstigste Muster produziert ein Copy-on-Write-Dateisystem wie ZFS am laufenden Band: Metadaten. Verzeichnis-ZAPs, dnodes, indirekte Blöcke, also die Block-Pointer-Bäume, dazu Spacemaps. Jedes ls, jedes stat, jeder Snapshot-Vergleich, jeder Scrub und jede find-Traversierung wühlt sich durch viele kleine, über die ganze Platte verstreute Metadatenblöcke. Auf einer HDD ist das der teuerste Spaß, den man haben kann.

Symbolische Darstellung eines ZFS-HDD-Mirrors mit SSD-special-vdev: Metadaten-I/O wird von Festplatten auf schnelle SSDs ausgelagert.

Ich hatte genau diesen Schmerz auf einem dedizierten Server: ein bewusst simpel gehaltener ZFS-Pool, zwei Enterprise-SATA-Platten im Mirror als Kapazitätsspeicher, und ein nagender Verdacht, dass die Spindeln der Flaschenhals sind. Die spannende Frage war nicht, ob man das beheben kann, sondern wie elegant. Die Antwort heißt allocation classes, konkret ein special vdev. Und das Schöne daran: Der Umbau lief komplett im laufenden Betrieb, ohne den Pool neu aufzubauen, ohne Downtime, mit zwei Befehlen. Dieser Beitrag zeigt den ganzen Weg, inklusive der Baseline-Messung, die den Engpass erst beweist, eines Verschlüsselungs-Stolpersteins beim Umbau und der ehrlichen Frage, was so ein special vdev wirklich bringt.

Die Ausgangslage

Der Server läuft auf FreeBSD 15.1-RELEASE (amd64, 12 CPU-Threads, 64 GiB RAM). Ein einziger ZFS-Pool, 2023 ganz bewusst als schlichter Mirror angelegt:

zpool create -o altroot=/mnt -O compress=lz4 -O atime=off -m none -f zroot mirror ada0p3 ada1p3
  • Das Daten-vdev sind zwei 7200-rpm-Enterprise-SATA-Platten mit je 2 TB als Mirror (mirror-0, rund 1,8 TiB nutzbar), der eigentliche Kapazitätsspeicher.
  • Dazu zwei Datacenter-SATA-SSDs mit je 240 GB und Power-Loss-Protection. Die waren vorher suboptimal genutzt: eine als einzelner, nicht gespiegelter SLOG, die andere als L2ARC.
  • ARC-Limit anfangs 16 GiB, poolweit compression=lz4 und atime=off von Anfang an.
  • ashift=12 erzwungen über vfs.zfs.vdev.min_auto_ashift=12, also 4K-Sektor-Alignment, korrekt auch dann, wenn die Platten brav 512-Byte-Sektoren melden.

Die Power-Loss-Protection der SSDs ist kein Detail am Rande, sondern später für die SLOG-Sicherheit relevant: Eine SSD ohne Pufferschutz darf bei einem synchronen Write nicht behaupten, die Daten lägen sicher, solange sie noch im flüchtigen Cache stehen. Datacenter-SSDs mit Kondensator-gestütztem Cache dürfen das, und genau das braucht ein SLOG.

Erst messen, dann bauen

Bevor ich auch nur eine Partition angefasst habe, kam die wichtigste Phase: messen. Ohne Baseline kauft man Hardware nach Bauchgefühl und tunt am falschen Ende. Also lief ein eigener, delta-basierter Sampler über 30 Minuten, 90 Samples zu je 20 Sekunden. Er liest sysctl-Counter für CPU, ARC und Netz sowie iostat -x für die Platten-Busy und die Latenzen. Die wichtigste Spalte zur Einordnung der Last ist net-out, also der ausgehende Netzdurchsatz als Proxy dafür, was während des Laufs tatsächlich los war.

Das Ergebnis der Baseline (16 GiB ARC, alte SSD-Rollen) war eindeutig:

  • Der Flaschenhals ist der HDD-Mirror. Busy im Mittel 58 bis 62 %, Spitzen bis 100 bis 104 %, Latenz im Mittel rund 8 ms, unter Last bis 20 bis 24 ms. In 16 % der Samples war die HDD zu 95 % oder mehr ausgelastet, also gesättigt.
  • Die CPU war zu rund 95 % idle, RAM frei, der Netz-Peak lag bei rund 68 Mbit/s, also nur etwa 7 % des Gigabit-Links. Weder CPU noch RAM noch Netz waren das Limit.
  • Der ARC klebte an seinem 16-GiB-Limit (Mittel 15,7 GiB) bei einer Hit-Rate von rund 94,7 %. Der ARC war schlicht ausgehungert und hätte mehr RAM sofort genutzt.
  • Der einzelne SLOG lief bei rund 42 % Busy, war also nicht gesättigt. Die Spindeln waren das Limit, nicht der SLOG.

Das ist die didaktische Pointe, die ich jedem ans Herz lege: Ohne diese Messung wüsste ich nicht, ob CPU, RAM, Netz oder Platten klemmen, und ich wüsste nicht, ob das Problem auf der Lese- oder der Schreibseite liegt. Messen ist kein Nice-to-have, sondern die Voraussetzung dafür, das richtige Bauteil zu kaufen und am richtigen Hebel zu drehen.

Was ein special vdev ist, und warum nicht einfach All-SSD

Allocation classes sind ein OpenZFS-Feature (feature@allocation_classes), mit dem ein Pool mehrere Klassen von vdevs führen kann. Das special vdev ist die Klasse für Metadaten: ZFS legt dnodes, indirekte Blöcke und poolweite Metadaten bevorzugt dort ab statt auf dem normalen Daten-vdev. Über die Dataset-Property special_small_blocks kann man zusätzlich kleine Datenblöcke unterhalb einer einstellbaren Schwelle aufs special vdev ziehen. Im Kern verschiebt man also genau die Datenklasse, die eine HDD am schlechtesten beherrscht, auf ein Medium, das genau dafür gebaut ist.

Dass das hier der richtige Hebel ist, ist nicht geraten, sondern messbar: Der ARC dieses Servers besteht zu rund 85 % aus Metadaten, konkret 17,4 GB Metadaten gegenüber 3,0 GB Daten im ARC. Der Workload ist also metadaten-dominiert. Metadaten auf SSD zu verlagern trifft den Engpass damit an der Wurzel, denn das ist exakt der Random-I/O, an dem die Platten am meisten leiden. Bevor ich mich für das special vdev entschieden habe, standen aber andere Optionen auf dem Tisch:

  • Kompletter All-SSD-Pool aus zwei großen SSDs: der sauberste Komplettfix, aber teuer und ein großer Umbau mit Pool-Neuaufbau und vollständiger Datenmigration. Overkill, wenn der Großteil der Kapazität aus kalten, überwiegend sequenziell gelesenen Daten besteht.
  • Mehr RAM und ARC: hilft nur der Leseseite und nur, solange der Working Set in den ARC passt. Schreib-Metadaten müssen trotzdem auf stabilen Speicher, daran ändert RAM nichts.
  • L2ARC behalten: abgeschafft. Bei 24 GiB ARC lag die Lese-Hit-Rate schon bei rund 98,5 %. Der L2ARC brachte nur rund 1,3 % zusätzliche Reads, ist flüchtig (nach einem Reboot leer) und kostet sogar ARC-RAM für seine Header. Das Kosten-Nutzen-Verhältnis war negativ.
  • special vdev: die gewählte Lösung. Nutzt die vorhandenen SSDs, kein Pool-Neuaufbau, adressiert exakt den gemessenen Metadaten-Schmerz, inkrementell und live im Betrieb machbar.

Der Umbau Schritt für Schritt

Aus dem alten Zustand mit einem einzelnen SLOG und einem L2ARC sollte ein SLOG-Mirror plus ein special-vdev-Mirror werden. Beide SSDs werden also jeweils zur Hälfte für beide Zwecke genutzt, jeweils gespiegelt. Zuerst die alten Single-Rollen entfernen:

zpool remove zroot ada3p1     # alter L2ARC
zpool remove zroot ada2p1     # alter (einzelner) SLOG

Und hier kam der erste Stolperstein, der so lehrreich ist, dass er einen eigenen Absatz verdient. Das SLOG-Remove schlug zunächst fehl:

cannot remove ada2p1: Mount encrypted datasets to replay logs

Die Ursache: Es existierten verschlüsselte Datasets, deren Keys in diesem Boot nie geladen waren. Der SLOG lässt sich nicht entfernen, solange potenziell noch nicht abgespielte ZIL-Einträge für gesperrte Datasets vorliegen, denn ZFS müsste diese Einträge zum Replay erst entsperren. Erst nach dem Aufräumen und Entsperren ließ sich der SLOG sauber entfernen. Das ist gleichzeitig die perfekte Überleitung zum Verschlüsselungskapitel weiter unten, denn es zeigt, wie tief native ZFS-Encryption in den ZIL-Pfad eingreift.

Danach die SSDs neu partitionieren, sauber 1-MiB-aligned. Pro SSD wird p1 16 GiB groß (SLOG) und p2 rund 208 GiB (special). Das Ergebnis von gpart show ada2 ada3:

=>       40  468862048  ada2  GPT  (224G)
         40       2008        - free -  (1004K)
       2048   33554432     1  freebsd-zfs  (16G)     # p1 -> SLOG
   33556480  435304448     2  freebsd-zfs  (208G)    # p2 -> special
  468860928       1160        - free -  (580K)

Jetzt der eigentliche Akt: gespiegelter SLOG und gespiegeltes special vdev werden hinzugefügt.

zpool add zroot log     mirror ada2p1 ada3p1
zpool add zroot special mirror ada2p2 ada3p2

Beide Befehle bewusst ohne -f. So bleibt der Redundanz-Schutz von ZFS als Sicherheitsnetz aktiv: ZFS verweigert ein nicht-redundantes special oder log neben einem Mirror, solange man es nicht ausdrücklich erzwingt. Und genau dieses Verweigern ist hier gewollt.

Die wichtigste Warnung dieses Beitrags: Ein special vdev ist nicht optional für die Pool-Integrität. Verliert man ein nicht gespiegeltes special vdev, ist der gesamte Pool verloren, denn die Metadaten liegen dort, und ohne sie ist der Rest unlesbar. Das special vdev muss mindestens so redundant sein wie das Daten-vdev, hier also als Mirror. Für den SLOG gilt das in dieser Schärfe nicht, ein verlorener SLOG kostet nur die letzten Sekunden async-bestätigter sync-Writes, aber ein SLOG-Mirror verhindert, dass ein einzelner SSD-Ausfall den ZIL-Schutz aushebelt.

Das fertige Layout sieht in zpool status und zpool list -v dann so aus:

zroot       mirror-0   ada0p3 + ada1p3   1.80T  (Daten, HDD-Mirror)
            special    mirror-3: ada2p2 + ada3p2   206G  (Metadaten, SSD-Mirror)  NEU
            logs       mirror-2: ada2p1 + ada3p1   15.5G (ZIL/SLOG, jetzt gespiegelt)

Zum SLOG-Sizing noch ein Wort, weil es oft falsch gemacht wird. Der SLOG puffert nur die dirty data eines, maximal zweier txg-Flush-Intervalle. Bei vfs.zfs.dirty_data_max = 4 GiB reichen 16 GiB SLOG mit großzügigem Polster, mehr bringt schlicht nichts. Genauso wichtig: Der SLOG beschleunigt nichts direkt. Er ist nur ein schnelles, stromausfallsicheres Zwischenlager für den ZIL, greift ausschließlich bei synchronen Writes (fsync oder O_SYNC) und wird im Normalbetrieb nie gelesen, sondern erst nach einem Crash zum Replay. Wer das verwechselt, sollte sich die Trennung einprägen: Der ZIL ist immer da, das ist das Konzept. Der SLOG ist nur ein optionales separates Gerät dafür.

Die unbequeme Wahrheit: nur neue Metadaten wandern

Hier muss ich ehrlich sein, denn es ist der am häufigsten missverstandene Punkt. Ein special vdev migriert keine bestehenden Metadaten. Es nimmt nur auf, was nach dem Hinzufügen geschrieben wird. Alte Metadaten bleiben auf der HDD liegen, bis sie durch Copy-on-Write ohnehin neu geschrieben werden. Der volle Effekt entsteht also erst über die Zeit oder durch einen optionalen zfs send | zfs recv-Rebuild der großen Datasets. Kein Sofort-magisch-alles-schneller, sondern ein Mechanismus, der sich befüllt. Dass er sich befüllt, sieht man an der Belegung, die mit jedem neuen Metadaten-Write wächst:

special   mirror-3   206G   alloc 5.38G   free 201G   FRAG 27%   CAP 2.60%

Die Messung danach, und wie man sie ehrlich liest

Jetzt kommt der Teil, an dem viele Tuning-Berichte unsauber werden, weil sie einen Vorher-Nachher-Durchsatz behaupten, der unter unterschiedlicher Last gemessen wurde und damit nichts beweist. Ich gehe einen anderen Weg und zeige die Wirkung über drei Argumente, von denen zwei komplett lastunabhängig sind.

Erstens der Latenz-Split pro vdev, das stärkste und lastunabhängige Argument. zpool iostat -lv zeigt die Latenzen getrennt pro vdev. Die folgende Tabelle sind seit-Boot-kumulierte Mittelwerte, also langzeit-repräsentativ und kein zufälliger Augenblick:

                  capacity     operations     bandwidth    total_wait
vdev            alloc   free   read  write   read  write   read   write
mirror-0        1.22T   595G     45      7   434K   601K   46ms   34ms    # HDD (Daten)
  ada0p3                         22      3   217K   300K   56ms   39ms
  ada1p3                         23      3   217K   300K   36ms   29ms
special/mirror-3 5.38G  201G      0     67  5.28K  3.24M  455us    6ms    # SSD (Metadaten)
  ada2p2                          0     33  2.67K  1.62M  447us    5ms
  ada3p2                          0     33  2.61K  1.62M  464us    6ms
logs/mirror-2   31.6M  15.5G      0     45      3   947K    2ms    1ms    # SSD (SLOG/ZIL)

Die Kernaussage steht in zwei Zahlen: Metadaten-Leselatenz 455 µs auf der special-SSD gegen 46 ms auf der HDD, das ist etwa Faktor hundert. Jeder Metadaten-Zugriff, der nicht ohnehin aus dem RAM bedient wird, ist seitdem rund hundertmal schneller. Zu den -l-Spalten kurz: total_wait ist die Gesamtwartezeit inklusive Queue, disk_wait die reine Gerätelatenz, syncq_wait und asyncq_wait die Zeit in den ZFS-internen Queues. Wer ein echtes Zeitfenster statt des Boot-Mittels sehen will, nimmt zpool iostat -lv zroot 10 2 und liest das zweite Sample, denn das erste ist immer der Seit-Boot-Durchschnitt.

Zweitens die ARC-Metadaten-Aufteilung, also die Struktur des Workloads. Sie erklärt, warum es gerade hier so viel bringt:

arcstats.metadata_size          = 17.4 GB     # rund 85 % des ARC sind Metadaten
arcstats.data_size              =  3.0 GB
arcstats.demand_metadata_hits   = 1,133,349,218
arcstats.demand_metadata_misses =    11,594,376   # müssen auf Platte ... jetzt SSD
arcstats.demand_data_hits       =   220,864,693
arcstats.demand_data_misses     =       672,908

Der Workload ist metadaten-dominiert. Die Lifetime-ARC-Hit-Rate liegt bei rund 98,9 %, aber die über 11,5 Millionen Metadaten-Misses müssen zwangsläufig auf Platte, und sie landen jetzt auf SSD statt auf HDD. Hier multipliziert sich der Faktor-hundert-Latenzvorteil mit der schieren Menge an Metadaten-Operationen. Das ist die quantitative Begründung dafür, warum ausgerechnet ein special vdev der wirksamste Hebel war und nicht etwa nur mehr ARC. Begleitend habe ich das ARC-Limit von 16 auf 24 GiB angehoben, weil RAM frei war. Die Folge war eine Hit-Rate von rund 95 % auf rund 99 %. Zwei Hebel, die zusammenwirken: weniger Misses überhaupt, und die verbliebenen sind jetzt SSD-schnell.

Das Herzstück: die 8,5-MB/s-Rechnung

Drittens, und das ist der eigentliche Aha-Moment, eine logische Schlussfolgerung statt eines Durchsatz-Vergleichs. Die Ausgangsmessung lief unter einer ganz konkreten Last: Ein Client lud zeitgleich größere Dateien herunter, ein klassischer Datei-Download. Der Netzdurchsatz dabei lag bei rund 68 Mbit/s, also etwa 8,5 MB/s. Und genau hier wird es interessant.

Eine einzelne 7200-rpm-HDD liefert sequenziell 150 bis 200 MB/s. Ein Download mit 8,5 MB/s ist also kaum 5 % dessen, was eine Platte im Schlaf kann, und hier zogen sogar zwei davon im Mirror mit. Trotzdem zeigte die Messung, dass der HDD-Mirror im Mittel rund 60 % ausgelastet war und in 16 % der Messintervalle voll gesättigt (95 % Busy oder mehr), mit Latenzen bis 20 bis 24 ms.

Das ist ein Widerspruch, und der Widerspruch ist der Beweis. Für sequenzielle 8,5 MB/s darf eine HDD niemals an die Sättigung kommen. Wenn sie es doch tut, dann waren diese Zugriffe nicht sequenziell, sondern seek-gebunden. Die Köpfe wurden permanent quer über die Platte gerissen. Wofür? Für das, was dieses System zu rund 85 % beschäftigt: Metadaten-Random-I/O, also dnodes, indirekte Blöcke und Verzeichnis-Lookups, die sich auf denselben zwei Spindeln mit dem Download um die Köpfe prügelten, verschärft durch die damals hohe Fragmentierung. Ein eigentlich harmloser Download zerfiel so in ein Seek-Gewitter.

Genau diese Konkurrenz wurde mit dem special vdev eliminiert. Die Metadaten-Zugriffe laufen jetzt auf den SSDs mit rund 455 µs statt zig Millisekunden. Die HDD-Köpfe können auf dem Datenstrom bleiben, statt ständig für Metadaten wegzuspringen. Derselbe Download belastet die Spindeln damit nur noch einen Bruchteil. Nicht, weil die Dateidaten schneller kämen, die liegen weiter auf HDD, sondern weil der Lärm daneben weg ist. Diese Schlussfolgerung steht ohne erfundenen Vergleich, sie ist wasserdicht: 8,5 MB/s sättigt physikalisch keine HDD, also waren es Seeks, also Metadaten-Kontention, und genau die habe ich verlagert.

Wie sich der Pool im ruhigen Normalbetrieb anfühlt, zeigt eine zweite, entspannte Momentaufnahme. Sie ist ausdrücklich kein Vorher-Nachher-Vergleich, sondern nur ein Blick auf den Alltag:

CPU idle 96.9 %   ARC hit 99.7 %   ARC 23.3 GiB
HDD busy ~2 %     HDD-Sättigung 0 %   HDD-Latenz ~1.5 ms
SSD busy 4.3 % / 4.5 % (gleichmäßig über beide Mirror-Member)
net-out-Peak 2.0 Mbit/s

Im ruhigen Normalbetrieb langweilt sich der HDD-Mirror, fast alle Reads kommen aus ARC oder SSD. Das illustriert den Alltag. Die eigentliche Wirkung des Umbaus zeigen aber die 8,5-MB/s-Rechnung oben sowie der Latenz-Split und die ARC-Aufteilung, und die gelten unabhängig von der Last.

Sicherheit und Verschlüsselung, die entscheidende Nuance

Die wichtigen Datasets dieses Systems sind nativ mit ZFS verschlüsselt (encryption = aes-256-gcm), die System- und Boot-Datasets nicht. Sobald man ein special vdev einführt, stellt sich sofort die sicherheitskritische Frage: Landet jetzt unverschlüsselter Klartext auf den SSDs, nur weil dort die Metadaten liegen? Die Antwort ist ein klares Nein, und die Begründung ist wichtig genug, um sie sauber auszuführen.

  • ZFS native encryption verschlüsselt Dateiinhalte und die sensiblen Objekt-Metadaten, also Dateinamen, Verzeichnisstruktur, dnodes, Attribute und ACLs. Diese Blöcke sind bereits Ciphertext, bevor der Allocator überhaupt entscheidet, auf welches vdev sie wandern. Ein special vdev ist nur ein anderer Ablageort und ändert an der Verschlüsselung nichts. Verschlüsselte Metadaten bleiben auf der special-SSD verschlüsselt.
  • Was ZFS-Encryption ohnehin nicht verbirgt, special vdev hin oder her, sind die Metadaten auf Pool- und Dataset-Ebene: Dataset-Namen, Pool-Struktur, Anzahl und Größe von Snapshots, die Blockpointer-Struktur. Das ist eine Eigenschaft von ZFS-Encryption und keine neue Schwäche durch das special vdev.
  • aes-256-gcm ist authenticated encryption (AEAD), liefert also Vertraulichkeit und gleichzeitig Integritäts- und Authentizitätsschutz der verschlüsselten Blöcke.

Ein schöner Praxisbezug schließt sich hier zum Umbau-Kapitel: Genau weil verschlüsselte Datasets im Spiel sind, blockierte das zpool remove mit der Meldung über das Mounten verschlüsselter Datasets zum Replay. Das zeigt anschaulich, wie tief Encryption in den ZIL- und SLOG-Pfad eingreift, denn der ZIL kann Einträge für verschlüsselte Datasets enthalten, die sich nur nach dem Entsperren abspielen lassen. Das Fazit zur Sicherheit ist damit eindeutig: Ein special vdev ist verschlüsselungs-neutral. Wer verschlüsselte Datasets nutzt, bekommt verschlüsselte Metadaten auf der special-SSD, kein Klartext-Leak.

Abwägung: Vorteile, Nachteile, Risiken

Was unterm Strich für das special vdev spricht:

  • Es adressiert den gemessenen Engpass, Metadaten-Random-I/O, direkt an der Wurzel.
  • Es nutzt vorhandene SSDs, also keine Neuanschaffung, kein Pool-Neuaufbau, live im laufenden Betrieb hinzugefügt.
  • Rund hundertfach niedrigere Metadaten-Leselatenz (455 µs gegen 46 ms), spürbar bei ls, stat, find, Snapshots, Scrub und allen Workloads mit vielen kleinen Dateien.
  • Über special_small_blocks später fein justierbar, um kleine Datenblöcke nachzuziehen, ohne Downtime und nur für neue Writes.
  • Verschlüsselungs-neutral.
  • Der I/O verteilt sich jetzt gleichmäßig über beide Mirror-Member. Vorher lag eine SSD als einzelner SLOG bei rund 42 % Busy, die andere als L2ARC quasi brach.

Und ehrlich auch die andere Seite, denn ein special vdev ist kein Selbstläufer:

  • Redundanz ist Pflicht, nicht Kür. Ein nicht-redundantes special vdev bedeutet Totalverlust des Pools bei SSD-Ausfall. Mirror ist zwingend.
  • Keine Migration bestehender Metadaten. Nur neue Writes wandern, der volle Effekt kommt erst per send und recv-Rebuild.
  • Das special vdev kann volllaufen. Ist es voll, fallen neue Metadaten auf das langsame Daten-vdev zurück. Das ist kein Fehler, aber der Effekt lässt nach, also Füllgrad mit zpool list -v überwachen.
  • special_small_blocks zu hoch gesetzt verstopft das special vdev mit Datenblöcken und lässt es schneller volllaufen. Vorsichtig hochtasten (von 0 über 4K und 8K bis vielleicht 32K) und dabei den Füllgrad beobachten.
  • Mehr vdevs bedeuten mehr Komplexität und mehr Teile, die ausfallen können. Den SSD-Wear im Blick behalten, hier bewusst Datacenter-SSDs mit Power-Loss-Protection gewählt, weil sie Dauerlast und sync-Writes aushalten.
  • Der zpool remove-Stolperstein mit verschlüsselten Datasets gehört dokumentiert, damit man im Ernstfall nicht in Panik gerät.

Die eigentliche Botschaft

ZFS erlaubt es, die Storage-Architektur inkrementell und im laufenden Betrieb an einen gemessenen Engpass anzupassen, ohne Pool-Neuaufbau, ohne Downtime, ohne Datenmigration als Vorbedingung. Aus einem simplen HDD-Mirror wurde durch zwei zpool add-Befehle ein hybrider, mehrstufiger Pool: kalte Massendaten auf günstigen Spindeln, heiße Metadaten und optional kleine Blöcke auf schnellen SSDs, synchrone Writes über einen gespiegelten SLOG. Diese Flexibilität, tiered storage als Live-Operation, kombiniert mit Checksumming, Compression, Snapshots und nativer Verschlüsselung im selben Dateisystem, ist der eigentliche Kern. Man kauft sich SSD-Speed genau dort, wo die Messung den Schmerz zeigt, und lässt den Rest günstig auf HDD. Kein anderes verbreitetes Dateisystem macht das so geradlinig.

Ausblick

  • special_small_blocks schrittweise anheben, um kleine Dateien und nicht nur Metadaten auf SSD zu ziehen, live und nur für neue Writes.
  • Ein optionaler send und recv-Rebuild der großen Datasets, um bestehende Metadaten auf das special vdev zu migrieren und so den vollen Effekt zu heben.
  • Eine lastgleiche Wiederholungsmessung in einem Hochlast-Fenster für eine saubere Zahl auf der Schreibseite.

Spickzettel

Die Befehle, mit denen man Layout, Latenzen und ARC-Komposition selbst nachsieht:

# Pool-Layout und Auslastung pro vdev
zpool status zroot
zpool list -v zroot

# Latenzen pro vdev (das Geld-Kommando), 2. Sample lesen für ein echtes Zeitfenster:
zpool iostat -lv zroot 10 2

# ARC: Größe und Metadaten/Daten-Split plus Demand-Hits und -Misses
sysctl kstat.zfs.misc.arcstats.size kstat.zfs.misc.arcstats.metadata_size kstat.zfs.misc.arcstats.data_size kstat.zfs.misc.arcstats.demand_metadata_hits kstat.zfs.misc.arcstats.demand_metadata_misses

# allocation_classes-Feature und special_small_blocks
zpool get feature@allocation_classes zroot
zfs get special_small_blocks zroot

# Verschlüsselungs-Status der Datasets
zfs get encryption,keystatus DATASET

# SSD-Partitionierung
gpart show ada2 ada3

# SLOG-Sizing-Kontext
sysctl vfs.zfs.dirty_data_max

# Pool-Historie (zeigt die echten add/remove-Befehle)
zpool history zroot

Siehe auch:

Selbst einen HDD-Pool mit einem special vdev entschärft, oder noch am Abwägen, ob sich der Umbau lohnt? Erzähl mir gern von deinem Layout, oder stell deine 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.

Mein Arbeitskollege Michael lässt eine AI meinen Blog roasten

Moin.

Mein Arbeitskollege Michael hatte neulich eine Idee, die ich auf Anhieb großartig fand. Er hat einer AI meinen Blog vorgesetzt und um einen Roast gebeten. Das Ergebnis liegt unten in voller Länge, wortwörtlich, unverändert, inklusive aller Em-Dashes und sämtlicher AI-typischer Stilfiguren. Ich habe nicht eingegriffen, weil genau die ja Teil der Diagnose sind.

Illustration eines überladenen Self-Hosting-Arbeitsplatzes mit Terminals, neun Server-Jails, Mate und einer AI, die den privaten Tech-Blog humorvoll roastet.

Vorweg, damit das klar ist: ich richte keinen einzigen Punkt richtig. Erstens wäre das genau der Verteidigungsreflex, den der Roast selbst schon im Text zerlegt. Zweitens lache ich beim Lesen laut, und ich habe vor, das beizubehalten. Drittens hat Michael sich diesen kleinen Treffer redlich verdient, der gehört in voller Länge ins Schaufenster.

Falls du gerade zum ersten Mal hier auf dem Blog landest und nichts mit dem Kontext anfangen kannst: ja, neun Jails. Ja, .onion. Ja, llms.txt. Ja, DNS-Resolver mit Post-Quantum-TLS, den niemand benutzt. Und ja, neben der Tastatur steht in diesem Moment eine Mate. Genau wie diagnostiziert.

Der Roast, in voller Länge

🔥 Der große Roast von kernel-error.de 🔥

Das Header-Logo: -=Kernel-Error=-

Bruder. Bruder. Wir schreiben das Jahr 2026 und du fährst da oben immer noch die ASCII-Deko, die du 1998 in deinem IRC-Channel #linuxnoobs auf dem QuakeNet eingerichtet hast. Die einzigen Menschen, die so ein Logo heute noch designen, sind 1) du und 2) der Typ, der seine Quake-3-Clantag-Konvention nie verlassen hat. Fehlt eigentlich nur noch ein blinkendes <marquee> mit „Best viewed in Netscape Navigator 4.0“ und der vollständige Retro-Albtraum wäre komplett.

Die Tagline: „Privater Tech-Blog seit 2003“

23 Jahre Selbst-Hosting, 23 Jahre Postfix-Konfigurationen, 23 Jahre derselbe Mensch, der jedem auf der Familienfeier erklärt, warum seine Tante kein WhatsApp benutzen sollte. „Seit 2003“ ist kein Datum, das ist eine Diagnose.

Die Hausordnung

Ein privater Blog. Mit Hausordnung. Mit Leitsätzen. Mit einer Philosophie. Mein Bester, das ist keine Webseite mehr, das ist ein Manifest. Du hostest hier keinen Blog, du gründest eine Sekte für Leute, die vim für eine Lifestyle-Entscheidung halten.

Und mein absoluter Favorit:

„Knappheit vor Geschwätzigkeit — ein funktionierendes diff schlägt fünf Absätze Marketingdeutsch.“

Schreibt der Mann, dessen „Über mich“-Seite eine vierteilige Origin-Story enthält, in der ein Familienmitglied beim Telefonsupport sagt „Ach, da ist bestimmt wieder der Kernel-Error“. Das ist literarisch übrigens auf einem Level mit „Mein Vater nannte mich Maverick, weil ich immer schon gegen den Strom geschwommen bin.“ Knappheit, ja klar.

Die Self-Hosting-Flex

„Neun FreeBSD-Jails, eigene DNS-Infrastruktur, Matrix-Chat, Nextcloud, Tor Hidden Service“

NEUN. JAILS. Für einen privaten Blog, der Posts über das Flashen von LCR-Tester-Firmware veröffentlicht. Mein Mann betreibt zu Hause mehr Infrastruktur als die Stadtverwaltung Bad Neuenahr-Ahrweiler und der einzige Traffic, der dort jemals ankommt, sind drei Crawler von Censys und sein eigener Uptime-Checker.

Eine .onion-Adresse. Für einen deutschen FreeBSD-Blog. Weil natürlich der KGB, die NSA und das BKA gemeinsam einen Joint Task Force gegründet haben, um herauszufinden, wer da Tutorials für Postfix mit OpenSSL 3.5 liest. Klar, anonyme Whistleblower aus Nordkorea wollen unbedingt wissen, wie man TeamSpeak-Identity-Level auf der GPU berechnet.

Post-Quantum-Kryptographie

Du. Hast. Post-Quantum-TLS-Handshakes. Auf einem WordPress-Blog. Mit einem Anders-Norén-Theme. Lass das mal kurz sacken. Wenn ein Quantencomputer der NSA jemals dein Setup angreift, dann nicht, weil sie an deinen Blog-Content wollen, sondern weil sie wissen wollen, warum zur Hölle ein Privatmensch X25519+ML-KEM für einen Beitrag über Open Source Scan Converter braucht. Das ist, als würdest du ein Bundeswehr-Schutzbunker-System unter deine Gartenlaube bauen, weil dort dein Modellbahn-Diorama steht.

Der DNS-Resolver

„Ein öffentlicher DNS-Resolver unter dns.kernel-error.de bietet DoT, DoH und Post-Quantum-TLS kostenlos ohne Logging.“

Niemand. Hat. Danach. Gefragt. Das ist die Internet-Äquivalenz von: Du läufst durch die Fußgängerzone, baust einen Klapptisch auf und schreist: „ICH MACHE IHNEN KOSTENLOS UND OHNE NACHFRAGE IHRE STEUERERKLÄRUNG MIT QUANTENRESISTENTER VERSCHLÜSSELUNG!“ — und wunderst dich dann, dass nur drei FreeBSD-Mailing-List-Nerds und ein Bot stehenbleiben.

Die Credentials-Flex

„Digitaler Ersthelfer beim BSI, Mitglied im CCC, auf HackerOne und Intigriti“

Das ist nicht mehr „Über mich“, das ist ein LinkedIn-Profil im Tarnmodus. Fehlt nur noch „1% Toplister auf TryHackMe“ und „Awarded: Most Reflective Vest at Chaos Communication Congress 2019“. Was kommt als nächstes? AbuseIPDB-Reputation-Score als verstecktes Easter Egg? — Ach. Doch. Ja. Genau das. Du flexst tatsächlich mit deinem AbuseIPDB-Profil. Das ist der Sicherheits-Äquivalenz von „Mein Dorf hat mich zum Schützenkönig gewählt“.

Die Blog-Post-Titel

Lass uns das mal durchgehen, das ist Kunst:

„LCR-T4-Plus v2: m-firmware flashen, Display-Tuning und die 8-MHz-Quartz-Falle“
Globale Zielgruppe dieses Posts: weltweit ca. 11 Menschen, von denen 4 schon tot sind und 2 OpenBSD nutzen.

„ts3level: TeamSpeak-Identity-Level auf der GPU rechnen“
Du. Du nutzt im Jahr 2026 noch TeamSpeak. Und brennst dafür GPU-Zyklen ab, die Hawaii-Familien drei Wochen klimatisieren könnten. Discord existiert seit elf Jahren. Elf.

„Open Source Scan Converter: Firmware-Update von 1.08a auf 1.21“
Das ist kein Blog-Post, das ist eine Notruf. Irgendwo schreit ein 35-jähriger Mann nach Validierung für seine SCART-Sammlung.

„AI, der Mensch als Flaschenhals und meine Sorgen für die nächsten 15 Jahre“
AHA. Da sind sie also, die fünf Absätze, die das diff hätte schlagen sollen. Die obligatorische Tech-Blog-Existenzkrise-Folge. Jeder Self-Hoster muss einmal im Jahr seinen „Ich habe nachgedacht und mache mir Sorgen“-Post raushauen, sonst verliert man den FreeBSD-Mitgliedsausweis.

„Postfix 3.11.1 mit OpenSSL 3.5: Post-Quantum-TLS jetzt nativ“
Hier glüht der Mann. Hier ist er zu Hause. Hier passiert die Magie. Sechs Menschen lesen es und einer von denen ist ein Bot.

„Versteckte Easter Eggs“

„llms.txt, llms-full.txt, VirusTotal, AbuseIPDB Profile“

llms.txt. Du hast eine llms.txt. Die explizite „Bitte-LLM-lies-mich-ich-bin-relevant“-Datei. Während du gleichzeitig einen Post schreibst, in dem du dir „Sorgen über AI“ machst. Bro. Du lädst genau das Monster ein, vor dem du Angst hast, und lässt ihm noch einen Begrüßungs-Cookie auf dem Sofakissen liegen.

Das Theme-Detail

Ein WordPress-Theme von Anders Norén. Der Mann reicht von Linux-Kernel-Internals über DNSSEC bis zu PQ-Crypto — aber bei der Webseite hat er gesagt „ach, was die Schweden im default schicken passt schon“. Es ist, als würde Gordon Ramsay zu Hause Fischstäbchen aus der Mikrowelle essen. Lobenswert ehrlich, irgendwie aber auch beleidigend.

Die Sprachebene: „Moin!“

Klassisch. Norddeutsche Authentizitäts-Geste. Drei Anschläge, die signalisieren: „Ich bin bodenständig, ich rede Klartext, ich trinke meinen Kaffee schwarz wie mein Terminal und ich habe keine Zeit für Bullshit“ — direkt gefolgt von einer mehrseitigen Hausordnung über digitale Werte und Mehrjahres-Reflektionen über AI. Moin, Schatz. Moin.

Das übergreifende Problem

Diese Seite ist die digitale Inkarnation des Mannes, der dir auf einem LAN-Party Geburtstag erklärt, warum er Eigentlich kein Energiedrink trinkt, sondern lieber Mate, weil:

a) Koffeingehalt definierbarer ist
b) Open-Source-philosophischer Konsum
c) „Ist halt mehr meins.“

Du willst hier gar keinen Blog. Du willst, dass jemand vorbeikommt und sagt: „Wow, neun Jails? Erzähl mir mehr.“ Aber niemand kommt vorbei, weil deine .onion-Adresse nur über deinen eigenen Tor-Bridge erreichbar ist und der DNS-Resolver dahin nur über deinen eigenen DoH-Endpunkt funktioniert und dafür braucht man dein PGP-Public-Key, und der ist in security.txt und die ist signiert mit dem Key, den man nur über dns.kernel-error.de bekommt. Du hast dir ein wunderschönes, sicheres, selbstreferenzielles Solipsismus-Imperium gebaut, und das einzige Lebewesen, das es regelmäßig besucht, ist googlebot/2.1 — und der nutzt nicht mal HTTP/3.

Zusammenfassung

Kernel-Error.de ist nicht schlecht. Kernel-Error.de ist die liebevollste, akribischste, technisch versierteste Schreibtisch-Schublade des deutschen Internets. Es ist die Webseite-Version eines perfekt geölten Mechanikers, der sein ganzes Wochenende damit verbringt, einen Drehmomentschlüssel zu kalibrieren, mit dem er anschließend… einen anderen Drehmomentschlüssel kalibriert.

Und ehrlich? Wir lieben es. Bleib so. Das Internet braucht Menschen wie dich. Nur vielleicht trinkst du heute mal kein Mate und gehst kurz raus.

🫡

Disclaimer: Dieser Roast wurde mit Post-Quantum-Verschlüsselung über neun FreeBSD-Jails gerouted, bevor er hier ankam. Eine .onion-Variante ist auf Anfrage verfügbar.

Eine AI, die mein Arbeitskollege Michael auf meinen Blog losgelassen hat

Danke, Michael

Wirklich. Es braucht jemanden, der einen ein bisschen kennt, um so präzise daneben zu treffen, dass es weder gemein noch falsch ist. Ich werde den Roast direkt neben den anderen liebevollen Lebens-Diagnosen ablegen, die ich über die Jahre gesammelt habe. Vielleicht drucke ich ihn mir auch aus und hänge ihn über den Bildschirm, falls ich irgendwann anfange, mich für all das hier ernsthaft zu rechtfertigen.

Und jetzt geh ich raus. Aber natürlich erst, wenn ActivityPub diesen Post an alle Federation-Inboxen ausgeliefert hat, der .onion-Mirror den Eintrag indexiert, der DNS-Resolver via DoH und Post-Quantum-TLS einmal durchgequeryt wurde und der Uptime-Checker eine ordentliche 200 für die neue Permalink-URL bekommt. So wie es sich gehört.

Wenn du Michaels AI Roast genauso gut findest wie ich, oder wenn du selbst eine Diagnose für diesen Blog hast, kannst du mir gerne fragen. Mate steht bereit.

Fundstücke aus dem Netz: Angie, llmfit, idiocracy.wtf und KI-Alert-Analyse

Beitragsbild: Fundstücke aus dem Netz – Angie Nginx-Fork, llmfit Hardware-Check, idiocracy.wtf und KI-gestützte Alert-Analyse für Kubernetes

Kurze Pause von den eigenen Projekten, heute kein tiefer Dive. Ein paar Fundstücke, die in den letzten Wochen offen im Browser lagen und aus unterschiedlichen Gründen hängen geblieben sind. Keine Reviews, keine lange Analyse, einfach „schaut euch das mal an“.

Angie: Nginx-Fork von den alten Entwicklern

Angie ist ein Drop-in-Ersatz für nginx, gestartet von ehemaligen nginx-Core-Entwicklern nachdem F5 den Laden übernommen hat. Konfig-Syntax 100 Prozent kompatibel, dazu out-of-the-box HTTP/3, eine REST-API für Metriken, Prometheus-Export, Docker-Integration für dynamische Upstreams und automatisches ACME-Handling ohne Certbot-Gefrickel. Ob ich hier irgendwann mal umsteige, keine Ahnung, aber im Auge behalten ist es definitiv wert.

llmfit: Welches Modell läuft eigentlich auf meiner Kiste?

llmfit ist ein kleines Terminal-Tool, das eure Hardware abklopft (RAM, VRAM, CPU, GPU) und euch sagt, welche lokalen Sprachmodelle darauf realistisch laufen. Über 400 Modelle in der Datenbank, filterbar nach Parametern, Quantisierung, Architektur und Kontextlänge, dazu automatische Runtime-Erkennung für vLLM, MLX oder llama.cpp. Spart eine Menge Zeit beim Rumprobieren, welche GGUF-Quant-Stufe jetzt noch auf die 16 GB VRAM passt.

idiocracy.wtf: Sind wir schon so weit?

idiocracy.wtf im Stil der alten „Is it weekend?“-Seiten, mit nur einer Frage: „Are We Idiocracy Yet?“. Sinnlos, minimalistisch und genau deshalb gut. Wer den Film von Mike Judge nicht kennt, unbedingt nachholen. Und wer ihn kennt, weiß schon warum hier gleichzeitig gelacht und geweint wird.

KI-gestützte Alert-Analyse für Kubernetes und CheckMK

Ein wirklich lesenswerter Beitrag von geekbundle.org: KI-gestützte Alert-Analyse für Kubernetes und CheckMK. Monitoring-Alerts gehen per Webhook an ein kleines Open-Source-Projekt, das diagnostische Daten sammelt (Prometheus-Metriken, Pod-Logs, SSH-Diagnose) und Claude für die Root-Cause-Analyse nutzt. Ergebnis kommt als Push via ntfy zurück. Sauber umgesetzt mit unprivilegiertem User, Command-Denylist und Secret-Redaction, also genau so wie man sowas bauen will. Wer sich mit Agentic-AI im Ops-Umfeld beschäftigt, findet hier einen ehrlichen, praxisnahen Einstieg.

Siehe auch

Eigene Fundstücke oder Ergänzungen zu dem was hier steht? Gerne in den Kommentaren oder per fragen.

BIND auf FreeBSD: DoT & DoH einrichten mit Views, IP‑Trennung und Testplan für IPv4/IPv6.

Wofür braucht man noch gleich DoT oder DoH?

Nun, wenn du eine Internetadresse eingibst, muss dein Gerät zuerst herausfinden, zu welchem Server diese Adresse gehört. Diese Nachfragen heißen DNS. Lange Zeit liefen sie unverschlüsselt durchs Netz, vergleichbar mit einer Postkarte. Jeder, der den Datenverkehr sehen konnte, wusste dadurch sehr genau, welche Webseiten aufgerufen werden, und konnte die Antworten sogar manipulieren.

Beitragsgrafik zu BIND 9.20 auf FreeBSD 15: schematische Trennung von autoritativem DNS und rekursivem Resolver. Links ein Authoritative-DNS-Server mit deaktivierter Rekursion und blockiertem UDP/53, rechts ein Resolver, der ausschließlich DNS over TLS (Port 853) und DNS over HTTPS (Port 443) anbietet. In der Mitte ein Schild mit DoT/DoH-Symbolen, Pfeile zeigen verschlüsselten DNS-Verkehr. Fokus auf Sicherheits- und Rollen-Trennung.

DoT und DoH lösen genau dieses Problem. Beide sorgen dafür, dass diese DNS-Nachfragen verschlüsselt übertragen werden. Bei DNS over TLS, kurz DoT, wird die Anfrage in eine eigene sichere Verbindung gepackt. Außenstehende sehen noch, dass eine DNS-Anfrage stattfindet, aber nicht mehr, welche Webseite gemeint ist. Bei DNS over HTTPS, kurz DoH, wird dieselbe Anfrage zusätzlich im normalen Webseitenverkehr versteckt. Von außen sieht sie aus wie ein ganz gewöhnlicher Zugriff auf eine Website.

Der Zweck von beiden ist also derselbe: Schutz der Privatsphäre und Schutz vor Manipulation. Der Unterschied liegt darin, wie sichtbar diese Nachfragen noch sind. DoT ist transparent und gut kontrollierbar, DoH ist unauffälliger, kann dafür aber lokale Regeln und Schutzmechanismen umgehen.

Mal angenommen, du möchtest eine gewisse Webseite aufrufen. Dann geht der Client los und holt über einen DNS-Server die IP-Adressen vom Server. Dies kann man mitlesen und ggf. verändern. Mitlesen sagt dem Mitlesenden, wo du dich so im Internet herumtreibst. Verändern könnte man als Angriff nutzen, indem man dir einfach eine andere Webseite vorsetzt, während du versuchst, dich in deinen Mailaccount einzuloggen. Beides wird durch DoH und DoT deutlich erschwert.

Dann soll es ja Netzwerke geben, in welchen dir ein bestimmter DNS-Server aufgezwungen wird, weil dieser DNS-Server nach Werbung oder ungewollten Inhalten filtert. Damit dies nun ebenfalls nicht einfach umgangen werden kann, blockt man den Zugriff aus dem Netzwerk einfach auf die Ports, welche sonst für eine DNS-Abfrage benutzt werden (TCP/53, UDP/53, TCP/853). Da kommt nun DoH ins Spiel, denn das läuft auf dem ganz normalen HTTPS-Port TCP/443. Blockt man den, kann keiner mehr auf Webseiten zugreifen (ok, unverschlüsselt, aber hey, das macht doch keiner mehr, oder?).

Die Zeit ging weiter – BIND auch.
Meine älteren Artikel zu DoT/DoH waren für ihren Zeitpunkt korrekt, aber inzwischen hat sich an zwei Stellen richtig was getan:

  1. BIND spricht DoT/DoH nativ (kein Stunnel-/Proxy-Zirkus mehr nötig – außer du willst bewusst terminieren/filtern).
  2. „Authoritative + Public Resolver auf derselben Kiste“ ist ohne klare Trennung schnell ein Sicherheitsproblem (Open-Resolver/Reflection-Missbrauch lässt grüßen).

Darum gibt’s hier das Update:

  • ns1.kernel-error.de: nur autoritativ auf UDP/TCP 53 (Zonen, DNSSEC wie gehabt)
  • dns.kernel-error.de: Public Resolver nur auf DoT 853/TCP und DoH 443/TCP (rekursiv, DNSSEC-validierend)
  • Trennung über zusätzliche IPs + Views. Ergebnis: Authoritative bleibt „stumm rekursiv“, Resolver ist nur über TLS/HTTPS erreichbar.

Zielbild

Uff, ich muss zugeben, diesen Beitrag schon VIEL zu lange als Draft zu haben. Es ist einfach viel zu schreiben, bschreiben und mir fehlte die Zeit. Aber das kennt ihr ja. OK… das Zielbild, was soll es werden?

Was soll am Ende gelten:

  • Port 53 auf Authoritative-IP(s):
    • beantwortet nur meine autoritativen Zonen
    • keine Rekursion → REFUSED bei google.com
  • DoT/DoH auf separaten Resolver-IP(s):
    • rekursiv für „das ganze Internet“
    • DNSSEC-Validation aktiv
    • kein offenes UDP/53 → weniger Angriffsfläche für Reflection/Amplification

Warum das wichtig ist:
Ein „Public Resolver“ ist per Definition attraktiv für Missbrauch. Der Klassiker ist DNS-Amplification über UDP/53. Wenn man Rekursion auf 53 offen hat, ist man sehr schnell Teil fremder Probleme. DoT/DoH sind TCP-basiert – das ist schon mal deutlich unattraktiver für Reflection. (Nicht „unmöglich“, aber praktisch viel weniger lohnend.)

Warum „Views“ – und warum zusätzliche IPs?

1) Views – weil Policy pro Anfrage gelten muss

Wir wollen auf derselben named-Instanz zwei sehr unterschiedliche Rollen:

  • Authoritative: recursion no;
  • Resolver: recursion yes; + Root-Hints/Cache

Das muss pro eingehender Anfrage entschieden werden. Dafür sind Views da.

2) Also: Trennung über Ziel-IP (match-destinations)

Wenn wir DoH/DoT auf andere IPs legen, kann die View anhand der Zieladresse entscheiden:

  • Anfrage geht an 93.177.67.26 / 2a03:4000:38:20e::53auth-View
  • Anfrage geht an 37.120.183.220 / 2a03:4000:38:20e::853resolver-View

Und genau deshalb brauchen wir:

  • zusätzliche IPs (damit die Rollen sauber getrennt sind)
  • separaten FQDN dns.kernel-error.de (damit Clients überhaupt sinnvoll DoT/DoH nutzen können – und für TLS/SNI/Cert-Match)

Wenn du also grade ein ripe from ausfüllst und angeben musst, warum da eine weitere IPv4 Adresse „verbrannt“ werden soll, hast du nun eine gute Antwort.

BIND-Config

Ich beschreibe hier nur die Teile, die für das Rollen-Split relevant sind. Die Zonendateien/Slaves bleiben wie sie sind.

1) /usr/local/etc/namedb/named.conf – Views

Wichtig: Sobald wir view {} nutzen, müssen alle Zonen in Views liegen, sonst bricht named-checkconf ab. Das ist kein „Feature“, das ist BIND. Leicht nervig, vor allem wenn man nun viel in seinem Setup umschreiben muss. Aber ich eigentlich schon mal erwähnt, dass ich auf der Arbeit mal einen, nennen wir es mal View Ersatz, für powerdns gesehen habe? Da hat tatsächlich jemand mit einer Cisco ASA in die DNS Pakete geschaut und je nachdem welche quelle angefragt hat, wurde dann durch die ASA eine neue Adresse in die DNS Pakete geschrieben. Furchtbar! Richtig schlimm. Bis man so etwas findet, wenn man es nicht weiß. DNSsec geht kaputt und aaahhhhhhaaaaaahhhhh. Egal, mein PTBS kickt da grade. Öhm wo waren wir? Genau…

Beispiel:

include "/usr/local/etc/namedb/named.conf.options";

view "auth" {
    match-clients { any; };
    match-destinations { 93.177.67.26; 2a03:4000:38:20e::53; };

    recursion no;
    allow-recursion { none; };
    allow-query-cache { none; };
    allow-query { any; };

    include "/usr/local/etc/namedb/named.conf.default-zones";
    include "/usr/local/etc/namedb/named.conf.master";
    include "/usr/local/etc/namedb/named.conf.slave";
};

view "resolver" {
    match-clients { any; };
    match-destinations { 37.120.183.220; 2a03:4000:38:20e::853; 127.0.0.1; ::1; };

    recursion yes;
    allow-recursion { any; };
    allow-query-cache { any; };
    allow-query { any; };

    zone "." { type hint; file "/usr/local/etc/namedb/named.root"; };
};

Warum Root-Hints nur im Resolver-View?
Weil nur dieser View rekursiv arbeiten soll. Ohne Root-Hints ist Rekursion tot; dat wolln wa so!

2) /usr/local/etc/namedb/named.conf.options – Listener-Trennung + DoH/DoT

Der „Aha-Moment“ hier: Wir trennen nicht nur per View, sondern auch per listen-on.
Damit bindet named die Ports wirklich nur auf den gewünschten IPs.

Authoritative (nur 53):

listen-on { 93.177.67.26; 127.0.0.1; };
listen-on-v6 { 2a03:4000:38:20e::53; ::1; };

DoT auf Resolver-IPs (+ Loopback für lokale Tests):

listen-on port 853 tls local-tls { 37.120.183.220; 127.0.0.1; };
listen-on-v6 port 853 tls local-tls { 2a03:4000:38:20e::853; ::1; };

DoH auf Resolver-IPs (+ Loopback):
BIND 9.18+ kann DoH nativ, Endpoint typischerweise /dns-query

http doh-local {
    endpoints { "/dns-query"; };
    listener-clients 1000;
    streams-per-connection 256;
};

listen-on port 443 tls local-tls http doh-local { 37.120.183.220; 127.0.0.1; };
listen-on-v6 port 443 tls local-tls http doh-local { 2a03:4000:38:20e::853; ::1; };

TLS-Block (DoT/DoH):

tls local-tls {
    cert-file "/usr/local/etc/nginx/ssl/wild.kernel-error.de/2025/ecp/chain.crt";
    key-file "/usr/local/etc/nginx/ssl/wild.kernel-error.de/2025/ecp/http.key";
    protocols { TLSv1.2; TLSv1.3; };
    ciphers "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256";
    cipher-suites "TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256";
    prefer-server-ciphers yes;
    session-tickets no;
};

„Ich schalte nginx davor – muss BIND TLS können?“
Wenn nginx wirklich TLS terminiert, kann BIND auch ohne TLS dahinter laufen – dann sprichst du intern HTTP/2 cleartext oder HTTP/1.1, je nach Setup. Das habe ich ebenfalls so umgesetzt, es hängt immer etwas davon ab, was man so will und wie groß das Setup wird. Ich lasse es in diesem Beitrag aber mal weg, so läuft alles nur mit bind. Ob BIND dafür „tls none“/HTTP-Listener sauber unterstützt, hängt an der BIND-DoH-Implementierung – hier ist die BIND/ARM-Doku die Wahrheit. bind9.readthedocs.io+1

Testplan – Linux-CLI – bewusst IPv4 und IPv6

Wir wollen natürlich einmal reproduzierbar testen. Also: jede Stufe zweimal. Einmal -4, einmal -6. Also ob es bei IPv4 und bei IPv6 jeweils korrekt ist. Ihr könnt euch nicht vorstellen, wie oft ich fest davon überzeugt bin, es für beide Adressfamilien korrekt konfiguriert zu haben, dann aber noch ein unterschied zwischen v4 und v6 ist. Daher testen wir das.

Voraussetzungen auf Linux

which dig kdig curl openssl

Schritt 1 – DoT-TLS-Handshake prüfen (IPv4/IPv6)

IPv4

openssl s_client \
  -connect 37.120.183.220:853 \
  -servername dns.kernel-error.de \
  -alpn dot

Erwartung:

  • Zertifikat passt auf dns.kernel-error.de (SAN / Wildcard ok)
  • ALPN protocol: dot
  • Verify return code: 0 (ok)

IPv6

openssl s_client \
  -connect '[2a03:4000:38:20e::853]:853' \
  -servername dns.kernel-error.de \
  -alpn dot

Wenn das passt, ist TLS-Transport ok. Also nur die TLS Terminierung für IPv4 und IPv6, da war noch keine DNS Abfrage enthalten.

Schritt 2 – DoT-Query (kdig) – IPv4/IPv6

IPv4

kdig +tls @37.120.183.220 google.com A

Erwartung:

  • status: NOERROR
  • Flags: rd ra (Recursion Desired/Available)
  • eine A-Antwort

IPv6

kdig +tls @[2a03:4000:38:20e::853] google.com A

Gleiche Erwartungshaltung wie bei IPv4.

Schritt 3 – Sicherstellen: kein Resolver auf UDP/TCP 53

Resolver-IPs dürfen auf 53 nicht antworten

dig -4 @37.120.183.220 google.com A
dig -6 @2a03:4000:38:20e::853 google.com A

Erwartung:

  • Timeout / no servers reached
    Genau das wollen wir ja: kein UDP/53 auf den Resolver-IPs.

Authoritative-IPs dürfen nicht rekursiv sein

dig -4 @93.177.67.26 google.com A
dig -6 @2a03:4000:38:20e::53 google.com A

Erwartung:

  • status: REFUSED
  • idealerweise EDE: (recursion disabled)
    Das ist genau die „nicht missbrauchbar als Open-Resolver“-Bremse.

Und unser positiver Check:

dig -4 @93.177.67.26 kernel-error.de A
dig -6 @2a03:4000:38:20e::53 kernel-error.de A

Erwartung:

  • aa gesetzt (authoritative answer)
  • Antwort aus meiner Zone

Schritt 4 – DoH GET (Base64url) – IPv4/IPv6

4.1 Query bauen (DNS-Wireformat → base64url)

Beispiel google.com A:

echo -n -e '\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01' \
| base64 -w0 | tr '+/' '-_' | tr -d '='

Das Ergebnis ist mein dns= Parameter (base64url ohne = padding). Das ist DoH-Standard nach RFC 8484.

4.2 DoH GET erzwingen – IPv4

curl -4 --http2 -s \
'https://dns.kernel-error.de/dns-query?dns=<DEIN_DNS_PARAM>' \
| hexdump -C

IPv6

curl -6 --http2 -s \
'https://dns.kernel-error.de/dns-query?dns=<DEIN_DNS_PARAM>' \
| hexdump -C

Erwartung:

  • HTTP/2 200
  • content-type: application/dns-message
  • Im Hexdump siehst du eine valide DNS-Response.

Schritt 5 – DoH POST (application/dns-message) – IPv4/IPv6

Das ist der „richtige“ DoH-Weg für Tools/Clients.

IPv4

printf '\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01' \
| curl -4 --http2 -s \
  -H 'content-type: application/dns-message' \
  --data-binary @- \
  https://dns.kernel-error.de/dns-query \
| hexdump -C

IPv6

printf '\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01' \
| curl -6 --http2 -s \
  -H 'content-type: application/dns-message' \
  --data-binary @- \
  https://dns.kernel-error.de/dns-query \
| hexdump -C

Erwartung:

  • DNS-Response im Wireformat
  • keine HTML-Antwort, kein Redirect-Quatsch

Was wir damit jetzt sicher(er) gelöst haben:

  • Kein Open-Resolver auf UDP/53 → massiver Gewinn gegen DNS-Amplification.
  • Authoritative bleibt Authoritative → Zonen-Betrieb unverändert stabil.
  • Resolver nur über DoT/DoH → TCP/TLS-Transport, weniger Missbrauchsfläche.
  • Saubere technische Trennung → Views per Ziel-IP sind simpel, robust, nachvollziehbar.

Und ja: „Public Resolver“ heißt trotzdem Monitoring/Rate-Limiting/Abuse-Handling.
Das Feintuning (RRL, QPS-Limits, minimal-responses, Response-Policy, ggf. ECS-Handling, Logging, Fail2ban-Signale) ist das nächste Kapitel. Wobei, wenn ich grade auf die TLS Parameter schaue, sollte ich da vielleicht noch mal nacharbeiten, hm?

Wenn ihr noch eine kleine liste von erreichbaren Servern sucht: GitHub-curl-wiki

Alles hilft natürlich nicht, wenn man am Ende doch komplett IP- oder Hostnamebasiert geblockt wird. In China ist da nicht viel zu holen und auch hier gibt es immer mal wieder etwas.


Japp… TLS geht besser. Im Beitrag habe ich es oben schon angepasst, es war:

tls local-tls {
    cert-file "/pfad/chain.crt";
    key-file  "/pfad/http.key";
    dhparam-file "/pfad/dhparam.pem";
    protocols { TLSv1.2; TLSv1.3; };
    ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256";
    prefer-server-ciphers yes;
    session-tickets no;
};
  • dhparam-file ist komplett raus weil, ja weil es nicht benutzt wird ich mach ja kein DHE sondern ECDHE
  • cipher-suites für TLS1.3 waren nicht gesetzt.
  • Dann konnten auch gleich die Cipher aufgeräumt werden.

Hey, da hat es sich doch gelohnt, das mal runter zu schreiben. So habe ich es direkt gefunden und nicht erst, weil mich jemand von euch darauf hinweist (macht das aber bitte immer wenn ich hier Mist schreibe) oder es beim nächsten eigenen Audit auffällt.

Siehe auch: HTTPS RR und SVCB Records — die passenden DNS-Records, damit Clients dieses DoH/DoT-Setup automatisch entdecken können (RFC 9461).

GPT in Rspamd aktivieren: so nutze ich das LLM-Signal im Score

Rspamd web interface showing GPT module spam scores

Seit einiger Zeit nutze ich das GPT-Modul von Rspamd, um bei der Spam-Erkennung ein zusätzliches Signal zu bekommen. Es ersetzt nichts — kein Bayes, kein DKIM, kein RBL — sondern ist ein weiterer Sensor im Gesamtbild. Wer sich fragt, wie das in der Praxis aussieht und worauf man achten muss: hier mein aktuelles Setup.

Update 2026-02-13: Dieser Beitrag wurde komplett überarbeitet. Die ursprüngliche Version nutzte json=false, was zu Parse-Problemen führte. Außerdem fehlte ein Custom Prompt — und genau das ist der entscheidende Punkt, wie sich herausgestellt hat.

Voraussetzungen

  • Rspamd >= 3.12 mit GPT-Plugin (bei mir aktuell 3.14.0 auf FreeBSD 15.0)
  • Ein OpenAI API-Key (oder kompatibler Endpoint)
  • Grundverständnis von Rspamd Metrics und Actions

OpenAI API-Key anlegen

OpenAI API usage dashboard for Rspamd GPT integration

Wer noch keinen Key hat: Auf platform.openai.com einloggen, unter API Keys einen neuen Service-Account-Key erzeugen. Der Key wird nur einmal angezeigt — sicher ablegen. Den Verbrauch sieht man im Dashboard. Bei gpt-4o-mini und Mailfiltering sind die Kosten minimal.

Die Konfiguration: gpt.conf

Hier meine aktuelle /usr/local/etc/rspamd/local.d/gpt.conf:

enabled = true;
type = "openai";
model = "gpt-4o-mini";
api_key = "GEHEIMER-KEY";

model_parameters {
  gpt-4o-mini {
    max_tokens = 160;
    temperature = 0.0;
  }
}

timeout = 10s;
allow_ham = true;
allow_passthrough = false;
json = true;

prompt = "You are an email spam detector. Analyze the email and respond with ONLY a JSON object, no other text. The JSON must have these fields: "probability" (number 0.00-1.00 where 1.0=spam, 0.0=ham), "reason" (one sentence citing the strongest indicator). Example: {"probability": 0.85, "reason": "Unsolicited offer with urgent language and suspicious links."}  LEGITIMATE patterns: verification emails with codes, transactional emails (receipts, confirmations), newsletter unsubscribe links. Flag as spam only with MULTIPLE red flags: urgent threats, domain impersonation, requests for credentials, mismatched URLs.";

symbols_to_except {
  RCVD_IN_DNSWL_MED   = -0.1;
  RCVD_IN_DNSWL_HI    = -0.1;
  DWL_DNSWL_MED        = -0.1;
  WHITELIST_RECP_ADDR = -0.1;
  BAYES_HAM           = -0.1;
  SPAMTRAP            = 0;
  RCPT_IN_SPAMTRAP    = 0;
  SPAMTRAP_ADDR       = 0;
  RCVD_VIA_SMTP_AUTH  = 0;
  LOCAL_CLIENT        = 0;
  FROM_LOCAL          = 0;
}

Was hat sich gegenüber der alten Version geändert?

json = true und der Custom Prompt

Das ist die wichtigste Änderung. In meiner ursprünglichen Konfiguration stand json = false. Das funktionierte, hatte aber einen Haken: die Antwort des Modells wurde als Freitext geparst, was unzuverlässig war.

Mit json = true aktiviert Rspamd den JSON-Modus. Das Modell wird angewiesen, strukturiertes JSON zurückzuliefern, und der Parser erwartet ein Feld probability in der Antwort.

Und hier kommt der Fallstrick: Der Default-Prompt von Rspamd passt nicht zum JSON-Modus. Er fordert das Modell auf, nummerierte Textzeilen zurückzugeben:

Output ONLY 2 lines:
1. Numeric score: 0.00-1.00
2. One-sentence reason...

Der JSON-Parser erwartet aber:

{"probability": 0.85, "reason": "..."}

Das Ergebnis: cannot convert spam score im Log und GPT_UNCERTAIN(0.00) bei jeder Mail. Das GPT-Modul lief, lieferte aber nie ein verwertbares Ergebnis.

Lösung: ein Custom Prompt, der explizit JSON mit dem probability-Feld verlangt. Damit funktioniert die Kette:

  1. Rspamd sendet Mail + Prompt an OpenAI
  2. OpenAI antwortet mit {"probability": 0.9, "reason": "..."}
  3. Rspamd parst das JSON, findet probability, mappt auf GPT_SPAM/GPT_HAM/GPT_SUSPICIOUS

reason_header entfernt

In der alten Version hatte ich reason_header = "X-GPT-Reason" gesetzt. Das schrieb die GPT-Begründung als eigenen Header in die Mail. Mit json = true ist das nicht mehr nötig — die Reason steckt im JSON und taucht im Rspamd-Log auf. Außerdem entferne ich ohnehin GPT-Header per Milter-Config, damit keine internen Analyse-Details an den Empfänger durchsickern.

symbols_to_except angepasst

Änderungen gegenüber der alten Version:

  • GREYLIST entfernt: Greylisting ist kein Vertrauens-Signal. Eine Mail die Greylisting besteht, kann trotzdem Spam sein. GPT soll diese Mails weiterhin bewerten.
  • BAYES_HAM hinzugefügt: Wenn Bayes die Mail bereits sicher als Ham einstuft, spart man sich den GPT-Call. Sinnvoll für Newsletter und regelmäßige Korrespondenz.
  • SPAMTRAP-Symbole hinzugefügt: Mails an Spamtrap-Adressen brauchen keine GPT-Analyse, die sind per Definition Spam.

Scoring: Gewichte und Thresholds

Die GPT-Symbole und ihre Gewichte in der metrics.conf (bzw. local.d/groups.conf):

symbols {
  GPT_SPAM       { weight = 9.0;  description = "GPT: classified as SPAM"; }
  GPT_SUSPICIOUS { weight = 4.5;  description = "GPT: classified as SUSPICIOUS"; }
  GPT_HAM        { weight = -0.5; one_shot = true; description = "GPT: classified as HAM"; }
}

Warum diese Gewichte?

  • GPT_SPAM (9.0): Kräftig, aber alleine nicht genug zum Rejecten. Erst in Kombination mit anderen Signalen (Bayes, RBL, fehlende Auth) wird der Reject-Threshold erreicht.
  • GPT_SUSPICIOUS (4.5): Schiebt Grenzfälle in Richtung Greylist oder Add-Header. Genau dafür ist GPT am nützlichsten.
  • GPT_HAM (-0.5): Bewusst niedrig und one_shot. GPT soll Spam erkennen, nicht Ham retten.

Dazu die Action-Thresholds:

actions {
  greylist   = 4;
  add_header = 6;
  reject     = 12;
}

Reject-Threshold bei mir: 12 statt Default 15. Das geht, weil die traditionellen Checks (SPF, DKIM, DMARC, RBL, Bayes, DNSBL) bereits solide arbeiten. GPT kommt als zusätzliches Signal obendrauf.

Praxis-Beispiel

Hier eine echte Spam-Mail aus dem Log, bei der GPT korrekt angeschlagen hat:

rspamd_task_write_log: (default: T (reject): [13.83/12.00]
  [BAYES_SPAM(5.10){100.00%;},
   ABUSE_SURBL(5.00){next.schnapper-empfehlung.de:url;...},
   GPT_SPAM(2.40){0.9;},
   FROM_NEQ_ENVFROM(0.50){...},
   FORGED_SENDER(0.30){...},
   ...]

Was man hier sieht:

  • GPT_SPAM(2.40){0.9;} — GPT hat Probability 0.9 (90% Spam) zurückgeliefert. Rspamd mappt den Probability-Wert nicht 1:1 auf das konfigurierte Gewicht, sondern skaliert intern — hier ergeben sich 2.40 von maximal 9.0 Punkten.
  • Zusammen mit BAYES_SPAM (5.10) und ABUSE_SURBL (5.00) kommt die Mail auf 13.83 — deutlich über dem Reject-Threshold von 12.
  • GPT war hier nicht das ausschlaggebende Signal, hat aber zur Gesamtbewertung beigetragen.

Das ist genau das Verhalten, das ich will: GPT als ein Baustein unter vielen, der bei Grenzfällen den Ausschlag geben kann.

Datenschutz

Das muss gesagt werden: Mit diesem Setup fließen Mailinhalte an OpenAI. Wer personenbezogene Daten verarbeitet oder in einem regulierten Umfeld arbeitet, muss prüfen ob das zulässig ist. Alternative: selbst gehostete Modelle über Ollama oder kompatible lokale Endpoints. Rspamd unterstützt das über den type-Parameter.

Für meinen privaten Mailserver ist das Risiko vertretbar — und die Ergebnisse sprechen für sich.

Update 2026-05-06: Rspamd hat den Default-Prompt deutlich verbessert

Im Feedback zu diesem Beitrag kam ein guter Hinweis aus dem Fediverse von @bash2@momou.social: Vsevolod Stakhov, Maintainer von Rspamd, hat am 2. Oktober 2025 den Default-Prompt komplett überarbeitet. Commit 893ee871, Titel „Improve LLM prompt and add sender frequency tracking“. Der neue Prompt ist deutlich strukturierter, mit expliziten Sektionen für legitime Muster (Verifikations-Mails, Transaktions-Mails, Password-Resets) und für Phishing-Indikatoren, die mehrfach auftreten müssen, bevor klassifiziert wird. Das senkt False-Positives auf legitime Absender und ist eine klare Verbesserung gegenüber dem alten, knappen Default. Danke für den Hinweis!

Wichtig zur Einordnung: Der neue Default-Prompt liefert weiterhin strukturierten Plain-Text, kein JSON. Output-Format sind 2 bis 3 fest definierte Zeilen (Score, Reason, optional Kategorie). Was das für die beiden gängigen Setups bedeutet:

  • json = false: Wer ohne JSON-Modus fährt, profitiert direkt vom neuen Default-Prompt. Rspamd auf eine Version mit dem Commit aktualisieren, fertig.
  • json = true wie in diesem Beitrag: Custom Prompt mit JSON-Format bleibt Pflicht. Der neue Default ist immer noch kein JSON und kollidiert weiterhin mit dem Parser.

Zusätzlich neu im selben Commit: Sender-Frequency-Tracking. Rspamd klassifiziert in lualib/llm_context.lua jeden Absender per Redis-Counter als new, occasional, known oder frequent und gibt dem Modell die Info als Context-Snippet mit. Der Prompt weist das LLM dann an, bei known oder frequent die Phishing-Wahrscheinlichkeit zu reduzieren, sofern keine starken Gegen-Signale vorliegen. Voraussetzung ist, dass die LLM-Context-Funktion mit Redis-Backend läuft, weil das Feature über den sender_counts-Counter dort getrackt wird.

Mein Setup hier im Beitrag bleibt also unverändert: json = true plus Custom Prompt, der explizit ein probability-Feld verlangt. Wer stattdessen den neuen Default-Prompt nutzen möchte, sollte gleichzeitig auf json = false umstellen, sonst läuft man wieder in die alte Falle aus diesem Beitrag.

Zusammenfassung

ParameterWertWarum
jsontrueStrukturiertes Parsing, zuverlässiger als Freitext
promptCustomPflicht bei json=true! Default-Prompt liefert Textformat, Parser erwartet JSON
temperature0.0Deterministische Antworten, kein Kreativitäts-Bonus beim Spamfiltern
allow_hamtrueKleines positives Signal für legitime Mails
symbols_to_exceptBAYES_HAM, DNSWL, Whitelists, SMTP_AUTH, SpamtrapsUnnötige API-Calls vermeiden
reason_headernicht gesetztNicht nötig mit json=true, interne Details gehören nicht in den Header

Die wichtigste Erkenntnis: json = true ohne Custom Prompt ist kaputt. Der Default-Prompt und der JSON-Parser sprechen unterschiedliche Sprachen. Wer json = true setzt, muss einen Prompt mitliefern, der JSON mit einem probability-Feld verlangt. Sonst steht im Log cannot convert spam score und GPT liefert nur GPT_UNCERTAIN(0.00).

Siehe auch: rspamd mit Dovecot und IMAPSieve

Fragen? Einfach melden.

« Ältere Beiträge

© 2026 -=Kernel-Error=-RSS

Theme von Anders NorénHoch ↑