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

Kategorie: Kernel-Error-Blog (Seite 1 von 50)

Persönlicher Tech-Blog von Sebastian van de Meer — Beiträge zu IT-Security, Netzwerken, FreeBSD, Linux, Elektronik und Maker-Projekten.

RFC 9989 löst RFC 7489 ab: was sich für DMARC wirklich ändert

Beitragsbild zu RFC 9989 und DMARC, Vergleich mit RFC 7489, neuen DMARC-Tags und E-Mail-Sicherheitsdarstellung

Stefan Haun von cyberscale.io hat mir per Mail einen Hinweis geschickt: mein alter Beitrag zu DMARC bezieht sich auf RFC 7489, das seit Mai 2026 durch RFC 9989 abgelöst ist. Der Hinweis kam, so fühlt es sich für mich an, ggf. im Rahmen von Linkbuilding, mit Verweis auf seinen eigenen Guide zu DKIM und DMARC. Das schmälert die fachliche Korrektheit des Hinweises nicht, deshalb hier der ehrliche Dank und der längst überfällige Nachtrag. Mitbekommen hatte ich die neue RFC schon vor einer Weile, geschrieben aber noch nichts, weil es bei einem frischen RFC erstmal eine Zeit braucht, bis sich das in der Praxis herumspricht und Implementierungen nachziehen. Genau dieser Verzug taucht unten beim Praxis-Check nochmal auf.

Was RFC 9989 überhaupt ist

In der Community läuft das Projekt unter dem Namen DMARCbis. Anders als der Name vermuten lässt, ist daraus keine einzelne neue RFC geworden, sondern drei:

  • RFC 9989, die Kernspezifikation: Policy, Alignment, die Tags im Record.
  • RFC 9990, die aggregierten Reports, also die täglichen XML-Berichte.
  • RFC 9991, die Failure Reports, das ARF-Format pro Nachricht.

Alle drei tragen offiziell „Obsoletes: 7489“ im Kopf, RFC 9989 zusätzlich noch „Obsoletes: 9091“ (der 2021 veröffentlichte, experimentelle Vorläufer, der np und die Public-Suffix-Domain-Idee einführte und dessen Inhalt RFC 9989 jetzt vollständig integriert und ersetzt). Dazu ein Reifegrad-Sprung, der eine eigene Erwähnung wert ist: RFC 7489 war „Informational“, RFC 9989 ist „Standards Track“, also auf dem offiziellen Weg zum Internet Standard. DMARC verlässt damit den Status der informellen Beschreibung eines etablierten Verfahrens und wird zu einer echten IETF-Spezifikation.

Die Tags: drei raus, drei gegenüber RFC 7489 neu

Der eigentliche technische Kern steckt in der Menge der Policy-Tags im Record. Drei Tags sind aus der aktiven Spezifikation gestrichen (in der IANA-Registry stehen sie weiterhin, nur als historic markiert), drei sind gegenüber RFC 7489 neu, wobei np davon eigentlich ein Wiedersehen ist:

Entfernt (RFC 7489)Neu gegenüber RFC 7489 (RFC 9989)
pct, Prozent-Rollout der Policyt, expliziter Test-Modus
rf, Report-Format, quasi nie genutztnp, Policy für nicht existierende Subdomains, importiert aus RFC 9091
ri, Report-Intervall, von Empfängern ignoriertpsd, Public-Suffix-Domain-Flag

rf und ri werden von einem RFC-9989-konformen Empfänger nicht mehr ausgewertet, RFC 9989 selbst nennt für die Streichung keine ausführlichere Begründung als das Fehlen praktischer Relevanz. pct ist der interessantere Fall, dazu gleich mehr.

t: der Testmodus, der pct ersetzt

t=n ist der Default und bedeutet normale Durchsetzung. t=y setzt die in p, sp oder np konfigurierte Policy eine Stufe herab: aus reject wird quarantine, aus quarantine wird none. Reports laufen dabei unverändert weiter, man sieht also weiterhin, wer durchfallen würde. RFC 9989 selbst nennt t ausdrücklich nur einen Ersatz für einen Teil der alten pct-Funktionalität, nämlich für die beiden Randwerte: t=n und t=y sollen sich bei Empfängern und Zwischenstationen analog zu pct=100 und pct=0 verhalten, nicht zu jedem beliebigen Prozentwert dazwischen. pct=0 unter RFC 7489 führte nach Abschnitt 6.6.4 bei reject ebenfalls schon zu quarantine statt zu gar keiner Reaktion, t=y übernimmt diesen einen Spezialfall, nur ohne Prozentrechnung. Für einen echten Teil-Rollout mit Werten zwischen 0 und 100 gibt es dagegen keinen direkten Nachfolger, dazu unten mehr.

np: eine eigene Stufe für nicht existierende Subdomains

Unter RFC 7489 galt für Mail von einer Subdomain, egal ob sie einen eigenen DNS-Eintrag hat oder nicht, immer dieselbe Regel: sp, falls gesetzt, sonst p. Eine Subdomain, die gar nicht existiert, zum Beispiel irgendwas.example.com, wo irgendwas nie angelegt wurde, bekam also exakt dieselbe Policy wie eine echte, konfigurierte Subdomain. Genau das ist ein bekanntes Spoofing-Einfallstor, weil sich beliebige, nie existierende Namen fälschen lassen, für die es nie eine eigene Policy gab. np schafft jetzt erstmals eine eigene, von sp und p unterscheidbare Stufe genau für diesen Fall. Neu erfunden ist das Tag dabei nicht: RFC 9989 übernimmt es wörtlich aus dem 2021 veröffentlichten, experimentellen RFC 9091, das mit RFC 9989 ebenfalls obsolet wird, und macht daraus einen festen Bestandteil der Hauptspezifikation. Fehlt np weiterhin, fällt die Regel auf sp zurück, falls gesetzt, sonst auf p, genau wie in RFC 9989 Abschnitt 4.7 definiert. Wer schon vorher ein striktes sp=reject gesetzt hatte, bekommt also weiterhin exakt das gleiche Verhalten wie zuvor, nur jetzt mit der Möglichkeit, es bei Bedarf davon zu lösen.

DNS-Tree-Walk statt Public Suffix List, psd markiert die Grenze

Die eigentliche Änderung ist strukturell: RFC 9989 löst die Bestimmung der Organizational Domain von der statischen, extern gepflegten Public Suffix List und ersetzt sie durch einen DNS-Tree-Walk-Algorithmus. psd (Werte y, n, u, Default u) liefert dafür die expliziten Grenzmarkierungen: psd=y setzt ein Public Suffix Operator auf seinem eigenen Record, um zu sagen, dass darunter fremde, eigenständige Organizational Domains beginnen, psd=n sagt umgekehrt, dass genau diese Domain die Organizational Domain für sich und ihre Subdomains ist. Relevant ist das vor allem für Registries und Hoster, die selbst öffentlich registrierbare Second-Level-Domains betreiben, also Strukturen wie bei .co.uk. RFC 9989 weist ausdrücklich darauf hin, dass Public-Suffix-List-Auflösung und Tree-Walk in Einzelfällen zu unterschiedlichen Ergebnissen kommen können. Für eine gewöhnliche Domain wie diese hier, ohne PSO-Rolle, ändert sich am eigenen Verhalten dadurch nichts.

Macht das bestehende Records ungültig? Nein, by design, mit einer Ausnahme in beide Richtungen

DMARC trägt seit RFC 7489 die Regel, dass unbekannte Tags ignoriert werden müssen. RFC 9989 übernimmt das wörtlich und baut die ganze Abwärtskompatibilität darauf auf. Ein alter, nur RFC-7489-konformer Validator, der t oder np nicht kennt, überliest sie einfach und wendet weiterhin p und sp an. Er stürzt dabei nicht ab und verhält sich nicht falsch, er kennt nur die feinere neue Steuerung noch nicht. Das hat aber eine Kehrseite, die man beim Einsatz von t=y kennen sollte: genau derselbe alte Validator ignoriert auch t=y und wendet bei p=reject weiterhin reject in voller Härte an. t=y schützt also nur gegenüber Empfängern, die RFC 9989 bereits verstehen, nicht gegenüber alten Implementierungen. Umgekehrt ignoriert ein RFC-9989-konformer Validator ein vorhandenes pct, weil das Tag schlicht nicht mehr definiert ist.

Genau an dieser Stelle steckt die Nuance, die wehtun kann: wer noch mit pct zwischen 1 und 99 einen echten Teil-Rollout fährt, das klassische schrittweise Hochfahren auf reject mit einem Bruchteil der Nachrichten, bekommt bei einem RFC-9989-konformen Empfänger keine reduzierte Durchsetzung mehr. Das Tag wird ignoriert, die volle in p konfigurierte Policy greift sofort. Einen direkten Nachfolger für genau diesen Zwischenbereich gibt es nicht, t kennt nur an und aus. Wer so einen Teil-Rollout fährt, muss stattdessen auf eine echte Stufenmigration ausweichen, etwa p=none, dann p=quarantine, dann p=reject, mit t=y jeweils als zusätzliche Bremse innerhalb einer Stufe. Nur der Randfall pct=0 hat mit t=y einen echten, direkten Ersatz. Für alle anderen gilt: bestehende v=DMARC1-Records bleiben syntaktisch gültig, niemand muss etwas ändern, um funktionsfähig zu bleiben. Weitgehend kompatibel ist die richtige Beschreibung, additiv dagegen nicht ganz, weil eben doch drei Tags aus der aktiven Auswertung verschwinden und die Organizational-Domain-Bestimmung sich strukturell ändert.

Mailinglisten und die p=reject-Warnung

Abschnitt 7.4 rät Domains, deren Nutzer möglicherweise auf öffentlichen Mailinglisten schreiben, ausdrücklich von p=reject ab. Der Grund ist alt und in RFC 7960 im Detail beschrieben: Listen-Reflektoren brechen die SPF-Alignment, weil die Mail über einen fremden Server läuft, und selbst DKIM-Signaturen überleben nicht jede Listen-Software unverändert. Der konkrete, im RFC-Text selbst genannte Weg ist eine gestufte Migration: zuerst mindestens einen Monat p=none, dann mindestens ebenso lange p=quarantine, jeweils mit Auswertung der Reports, bevor überhaupt über p=reject nachgedacht wird. t=y wird an dieser Stelle im RFC nicht namentlich erwähnt, würde sich als zusätzliche Bremse innerhalb einer Stufe aber inhaltlich genauso eignen wie der frühere pct-Ansatz, das ist an dieser Stelle meine eigene Einordnung, keine RFC-Vorgabe. Wichtig zusätzlich, weil es die Wucht von p=reject etwas relativiert: RFC 9989 verpflichtet Empfänger, nicht allein aufgrund eines p=reject abzulehnen, und schreibt vor, einen DMARC-Fail ohne weitere eigene Erkenntnisse wie quarantine zu behandeln.

RFC 9990: DKIM-Ergebnisse und der Selector werden verbindlicher

In den regelmäßigen, in der Praxis meist täglichen aggregierten XML-Reports war das DKIM-Auth-Ergebnis unter RFC 7489 eher beiläufig spezifiziert. RFC 9990 macht daraus eine klare Pflicht, allerdings nur bedingt: wurde für eine Nachricht überhaupt eine DKIM-Signatur validiert, muss das Ergebnis im Report stehen, eine komplett unsignierte Nachricht braucht weiterhin kein eigenes DKIM-Element. Neu und für die Praxis besonders nützlich ist zusätzlich, dass der verwendete Selector jetzt ebenfalls verpflichtend im Report steht, unter RFC 7489 war er optional. Damit lässt sich aus den Reports direkt ablesen, welcher Selector bei welchem berichtenden Mail-Receiver (nicht) verifiziert, was Diagnosen bei DKIM-Key-Rotationen deutlich erleichtert.

RFC 9991: Failure Reports und die Datenschutzfrage

Das ARF-Format für die einzelnen Failure Reports ist nicht neu, das nutzte schon RFC 7489. Neu ist, dass RFC 9991 dieses Thema aus der Kernspezifikation herauslöst, in eine eigene RFC packt und dabei ein altbekanntes Problem konkreter adressiert: Failure Reports können personenbezogene Daten enthalten, Empfängeradressen und im schlechtesten Fall Teile des Mail-Inhalts. Genau deshalb liefern nach eigener Beobachtung viele große Empfänger schon seit Jahren kaum bis gar keine ruf-Reports aus, RFC 9991 selbst schreibt, dass die Datenschutzbedenken viele Betreiber dazu gebracht haben, den Einsatz von Failure Reports einzuschränken. RFC 9991 bringt jetzt konkrete Redaction-, Datenminimierungs- und Transport-Security-Empfehlungen mit, formuliert als starke Empfehlung, nicht als Pflicht oder Garantie. Ob das in der Praxis tatsächlich mehr Empfänger dazu bringt, ruf überhaupt zu bedienen, bleibt abzuwarten, ein Schritt in die richtige Richtung ist es trotzdem.

Praxis-Check an den eigenen sechs Domains

Live abgefragt mit dig +short TXT _dmarc.<domain>, Stand heute:

kernel-error.de:        v=DMARC1; p=reject; rua=mailto:postmaster@kernel-error.de; ruf=mailto:postmaster@kernel-error.de; fo=1; sp=reject; aspf=s;
kernel-error.com:       (identisch)
kernel-error.org:       (identisch)
vandemeer.de:           (identisch)
fuchs-meckenheim.de:    (identisch)
heidbreders.de:         (identisch)

Drei Beobachtungen dazu. Erstens: pct=100 stand ursprünglich noch auf allen sechs Records, ein Überbleibsel aus der Zeit vor dem eigentlich gewünschten vollen Rollout, dazu weder rf noch ri. Weil überall ohnehin die volle Durchsetzung gewollt war und kein Teil-Rollout lief, war die neue Bedeutungslosigkeit von pct genau der Anlass, das Tag von allen sechs Records ersatzlos zu streichen, kein Muss, aber ein guter Moment dafür.

Zweitens: heidbreders.de stand bislang als einzige der sechs Domains auf sp=none, die anderen fünf auf sp=reject. Beim Aufräumen gleich vereinheitlicht, jetzt tragen alle sechs sp=reject. np explizit zu setzen würde am eigenen Verhalten trotzdem nichts ändern, weil der Default, der Rückfall auf sp, für alle sechs Domains ohnehin schon reject ist. Es besteht kein Handlungsbedarf, ein expliziterer Record wäre höchstens Geschmackssache.

Drittens, und das ist der Punkt, der zur eingangs erwähnten Adoptionsverzögerung passt: rspamd, hier in Version 4.1.0 der eigene DMARC-Validator für eingehende Mail, unterstützt np nach aktuellem Stand von September 2026 noch nicht. Bekommt der eigene Filter eine gespoofte Mail von einer nicht existierenden Subdomain einer fremden Marke, deren Domain-Owner über np strenger sein wollte als über sp, wendet rspamd aktuell trotzdem die laxere sp-Policy an. Eine kleine, aber reale Lücke auf der Empfänger-Seite, bis rspamd nachzieht. Vier Monate nach Publikation der RFC haben ohnehin die wenigsten Absender-Domains np überhaupt gesetzt, akuten Grund zur Sorge gibt das nicht, einen Blick in die rspamd-Release-Notes aber schon.

Die Frage aus Abschnitt 7.4 ist dabei nicht, ob ich selbst eine Mailingliste betreibe, sondern ob Absender meiner Domains an fremden Mailinglisten oder ähnlichen indirekten Mail-Flows teilnehmen. Bei mir ist das praktisch nicht der Fall, für die eigene Infrastruktur bleibt die gestufte Migration also Theorie. Für Leser mit Nutzern, die auf Mailinglisten schreiben, ist sie es sehr wohl.

Kurzfassung: Kein akuter Handlungsbedarf für Domains mit bestehendem p=reject und pct=100. Wer noch einen Teil-Rollout mit einem pct-Wert zwischen 1 und 99 fährt, findet in t keinen direkten Ersatz und muss auf eine echte Stufenmigration über p ausweichen, nur der Randfall pct=0 lässt sich eins zu eins durch t=y ersetzen. np ist ein sinnvolles Sicherheits-Add-on, dessen Wirkung von der Update-Geschwindigkeit der empfängerseitigen Implementierungen abhängt, ein gutes Beispiel dafür, dass ein neues RFC nicht am Tag der Veröffentlichung live ist, sondern über Monate und Jahre durch Software-Updates bei allen Beteiligten durchsickert.

Siehe auch

Eigene Erfahrungen mit der Umstellung auf RFC 9989, oder Fragen zu DMARC allgemein? Dann darfst du mich sehr gerne fragen.

Keyoxide: der fünfte Claim scheiterte an der Position im Profil, nicht am Beweis

Technische Illustration zum Keyoxide-Claim: Ein OpenPGP-Beweis wird im ActivityPub-JSON von Position fünf auf Position eins verschoben und anschließend erfolgreich verifiziert.

Zwei Keyoxide-Beiträge liegen schon hier, im ersten vier Claims über DNS, GitHub und Matrix, im zweiten die Linux-App und der Matrix-Kanal des Projekts. Was in beiden fehlte, war ein fünfter Claim, der genau dieses Blog beweist, den Fediverse-Actor @kernel-error.de@www.kernel-error.de. Der ist keine Mastodon-Instanz, sondern das WordPress-ActivityPub-Plugin, das aus diesem Blog selbst einen Actor macht. Im August habe ich genau diesen Claim gebaut, ausgerollt und noch am selben Tag wieder zurückgebaut, weil er einfach nicht grün wurde. Das hier ist die Geschichte, warum nicht, und was seit heute anders ist.

Der gescheiterte erste Versuch im August

Der Claim in der Selbstsignatur des Schlüssels: proof@ariadne.id=https://www.kernel-error.de/@kernel-error.de. Der Beweis dazu: ein Extra Field des ActivityPub-Plugins namens OpenPGP mit dem Wert openpgp4fpr:45FC…6DEB, exakt die Platzierung, die Keyoxide selbst als unterstützt dokumentiert. Der Actor lieferte auch wortgetreu {"type":"PropertyValue","name":"OpenPGP","value":"openpgp4fpr:45FC…6DEB"} aus. Trotzdem blieb der Claim rot.

Bei der Fehlersuche im August ist mir dabei ein echter Bug auf der eigenen Seite untergekommen und gleich mitgefixt: WordPress beantwortete OPTIONS-Anfragen auf die Actor-URL mit einem 301 auf die Startseite, ein umgeleiteter CORS-Preflight scheitert damit in jedem Browser hart. Eine eigene nginx-Location, die den Preflight selbst mit 204 beantwortet, hat das behoben, dauerhaft, unabhängig vom Rest dieser Geschichte, weil es jeden browserseitigen Consumer des Actors betrifft. Das steht bereits ausführlich im ersten Keyoxide-Beitrag.

Was im August systematisch ausgeschlossen wurde

Nach dem CORS-Fix ließ sich der Actor sauber abrufen, im Access-Log stand GET /@kernel-error.de 200 … "doipjs/2.1.0", das Werkzeug hinter Keyoxide hatte den Actor also tatsächlich in der Hand. Der Rest wurde einzeln durchgestrichen. Provider-Matching: die ActivityPub-Regel von doipjs ist ein Catch-all für jede https-URL, NodeInfo-Anfragen im Log belegten, dass die ActivityPub-Nachbearbeitung wirklich lief. Feld-Pfad: attachment.value ist einer von drei Pfaden, die doipjs durchsucht, neben summary und content. Groß- und Kleinschreibung des Fingerprints: doipjs kleinschreibt vor dem Vergleich, also irrelevant. Am Ende blieb nur ein Rückbau übrig, Notation raus, Redeploy über alle sieben Schlüssel-Kanäle, Feld gelöscht. Alles sah richtig aus, und trotzdem war es rot. Genau dieser Widerspruch ist der Grund, warum ich das jetzt, im September, zu Ende verfolgt habe.

Die Ursache im Verifizierer: doipjs und runJSON

doipjs ist die Bibliothek hinter keyoxide.org, Quelltext offen auf Codeberg. Die eigentliche Suchfunktion heißt runJSON(proofData, checkPath, params) und läuft einen JSON-Pfad ab. Zwei Zeilen daraus erklären den ganzen Fehler:

// Array: der Reihe nach abarbeiten, kein try/catch pro Element
if (Array.isArray(proofData)) {
  let result = false
  for (let index = 0; index < proofData.length; index++) {
    if (result) { continue }
    result = await runJSON(proofData[index], checkPath, params)
  }
  return result
}

// Objekt ohne den naechsten Schluessel im Pfad: wirft, sofort
if (typeof proofData === 'object' && !(checkPath[0] in proofData)) {
  throw new Error('err_json_structure_incorrect')
}

Steckt der Beweis in einem Array, iteriert runJSON über die Einträge und ruft sich selbst für jedes Element auf, ohne dabei einzelne Fehler abzufangen. Fehlt einem Objekt der nächste erwartete Schlüssel im Pfad, wirft die Funktion sofort. Beides zusammen bedeutet: im ersten Element eines Arrays, dem der gesuchte Schlüssel fehlt, bricht die ganze Suche ab, alles danach wird nie mehr angeschaut. Aufgefangen wird das erst eine Ebene höher, in run(), und zwar pro Zielpfad einzeln, summary, attachment.value, content bekommen je einen eigenen Try-Block. Ein Wurf in attachment.value gibt also nur diesen einen Pfad auf, summary und content laufen trotzdem weiter, finden dort aber naturgemäß nichts, weil der Beweis nur im Attachment-Array steht.

Die andere Hälfte: wie das ActivityPub-Plugin Profilfelder ausliefert

Die zweite Hälfte des Bugs steckt im WordPress-Plugin selbst, in class-extra-fields.php, Funktion fields_to_attachments(). Für jedes einzelne Profilfeld erzeugt die Funktion nicht ein Attachment, sondern zwei: zuerst ein klassisches PropertyValue mit name und value, das ist die Tabelle, die Mastodon rendert. Danach, als Unterstützung für FEP-fb2a, entweder ein Link mit href, wenn der Wert ein einzelner Link ist, oder ein Note mit content, wenn nicht.

Weder Link noch Note hat einen Schlüssel namens value. Im echten Actor sah das im August deshalb so aus:

attachment[0]  PropertyValue  Blog   value=...   geprueft, kein Treffer
attachment[1]  Link           Blog   href=...    kein value-Schluessel, wirft
attachment[2+] ...                               nie erreicht

doipjs prüft auf einem WordPress-Actor also faktisch nur attachment[0]. Die bestehenden Felder standen im August in der Reihenfolge Blog, GitHub, Matrix, Kontakt, das OpenPGP-Feld kam als fünftes dazu und lag damit nie an erster Stelle. Genau das erklärt jede einzelne Beobachtung von damals: erfolgreich abgerufen, korrektes JSON im Feld selbst, trotzdem rot.

Warum das auf Mastodon nie auffällt

Mastodon liefert für seine eigenen Profilfelder ausschließlich PropertyValue-Einträge aus, jedes Element im Array trägt also value, und die Schleife läuft ungestört durch, egal an welcher Position der Beweis steht. Der FEP-fb2a-Zusatz ist eine Erweiterung für genau die Fälle, die Mastodon nicht abdeckt, in diesem Fall anklickbare Links im Profil außerhalb der reinen Tabellenoptik. Niemand hat hier etwas falsch gemacht: das Plugin erweitert sinnvoll über Mastodon hinaus, doipjs ist gegen Mastodons Datenform geschrieben, und beide Annahmen treffen für sich genommen zu. Nur zusammen ergeben sie eine Reihenfolge-Abhängigkeit, die auf keinem der beiden Wege sichtbar wird, wenn man nur die eigene Seite testet.

Beim Gegenlesen dieser Analyse kamen noch zwei Präzisierungen dazu, die den Befund nicht ändern, aber schärfen. Erstens: ein Wurf in attachment.value beendet nicht die gesamte Verifikation, nur diesen einen Pfad, summary und content laufen wie oben beschrieben trotzdem weiter. Zweitens: ein reiner Unit-Test von verifications.run() übergeht den eigentlichen Netzwerk-Abruf, der wirkliche Beweis ist ein kompletter Durchlauf über Claim.match() und verify() gegen den lebenden Actor, dazu unten mehr. Vorschlag aus derselben Runde war außerdem, den Beweis zusätzlich oder stattdessen im Profil-summary zu platzieren, das ist positionsunabhängig. Entscheidung: das Extra Field bleibt, weil es das sauberere Profil ergibt, die Reihenfolge-Abhängigkeit wird dafür hier dokumentiert, summary bleibt die bekannte Rückfalloption.

Reproduziert, bevor irgendetwas am Schlüssel geändert wurde

Bevor der Schlüssel angefasst wurde, habe ich doipjs 2.1.0 lokal aus npm in ein Wegwerf-Verzeichnis installiert und dessen eigene verifications.run() plus die ActivityPub-Provider-Definition direkt gegen das echte, live abgerufene Actor-JSON laufen lassen, mit dem Beweis an unterschiedlichen Positionen eingesetzt:

live actor, unveraendert                             result=false errors=["err_json_structure_incorrect","err_json_structure_incorrect"]
OpenPGP an erster Stelle (Index 0)                   result=true  errors=[]
OpenPGP an letzter Stelle (Index 8), wie im August    result=false errors=["err_json_structure_incorrect","err_json_structure_incorrect"]
Nur PropertyValue (Mastodon-Form), OpenPGP letzte     result=true  errors=[]

Die beiden Fehler in den ersten zwei Zeilen sind der attachment.value-Pfad, dort steht der Link- oder Note-Eintrag ohne value, und der content-Pfad, ein Person-Actor hat kein Top-Level-content-Feld. Die letzte Zeile ist die Kontrollprobe: derselbe Beweis an derselben Position funktioniert, sobald das Array die reine Mastodon-Form hat. Vier Zeilen, die die ganze Geschichte erzählen. Der relative Importpfad war dabei nötig, doipjs/src/… lässt sich nicht als Package-Subpath importieren, die exports-Angabe in dessen package.json blockt das.

Der Fix auf dem Blog

Das Extra Field OpenPGP wurde neu angelegt, mit menu_order = -1, während die bestehenden vier auf 0 bis 3 stehen, Inhalt im selben Absatz-Blockformat, das das Plugin auch für die anderen Felder benutzt, <!-- wp:paragraph --><p>openpgp4fpr:45FC…6DEB</p><!-- /wp:paragraph -->. Eingefügt wurde das per direktem $wpdb->insert(), nicht über wp_insert_post(), weil Letzteres die ActivityPub-Hooks auslöst und ein Update für den Actor in die Outbox stellt, das später als Geister-Post auftaucht, Blog-Titel, Datum 2003. Gemessen: ap_outbox stand vorher und nachher bei genau demselben Wert. Danach zuerst der Redis-Object-Cache geleert, dann der nginx-FastCGI-Cache, die umgekehrte Reihenfolge würde FastCGI aus noch altem Redis-Inhalt neu befüllen. Im lebenden Actor sieht das Ergebnis jetzt so aus:

0 {"type": "PropertyValue", "name": "OpenPGP", "value": "openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB"}
1 {"type": "Note", "name": "OpenPGP", "content": "openpgp4fpr:45FCD081ADB54872EA5B06B9893DE0CDDE986DEB"}
2 {"type": "PropertyValue", "name": "Blog", "value": "<a href=...>"}
3 {"type": "Link", "name": "Blog", "href": "https://www.kernel-error.de", ...}

Der reine Text bekommt hier eine Note als Begleitung, kein Link drin, also kein Link-Eintrag. attachment[0] trägt jetzt den Beweis, genau da, wo doipjs zuerst und im Zweifel einzig hinsieht.

Die Gegenprobe vor der Schlüsseländerung

Nur der Reihenfolge-Test allein hätte mir nicht gereicht. Vor der eigentlichen Schlüsseländerung lief deshalb ein kompletter doipjs-Durchlauf, Abruf, Provider-Matching, Verifikation, Nachbearbeitung, gegen die produktive Seite, mit dem Fingerprint direkt übergeben, den Beweis brauchte der Schlüssel dafür noch nicht:

const claim = new Claim('https://www.kernel-error.de/@kernel-error.de', FINGERPRINT)
claim.match()      // ergibt: activitypub, owncast (die URI ist absichtlich mehrdeutig)
await claim.verify()
// ergibt: status 200 (VERIFIED), provider "wordpress" (aus NodeInfo),
//         profile "@kernel-error.de@www.kernel-error.de"

Erst nachdem dieser Test grün war, bekam der Schlüssel die Notation, dieselbe Regel wie schon bei den DNS-Claims: der Schlüssel behauptet nie öffentlich etwas, das noch nicht beweisbar ist.

Schlüssel und Redeploy über sieben Kanäle

Die Notation kam ausschließlich in die Namens-UID, in einer skriptgesteuerten gpg --batch --command-fd 0 --edit-key-Sitzung, sonst bekommt auch die Foto-UID den Claim mit. Danach geprüft: fünf Claims auf UID 1, Algorithmus-Präferenzen und Feature-Flags unverändert, die Governikus-eID-Zertifizierung und die Gegensignatur des alten Schlüssels weiterhin gültig, Ablaufdaten unverändert, Foto-UID unangetastet. Jede Claim-Änderung erzeugt eine neue Selbstsignatur, deshalb musste der aktualisierte Schlüssel auf alle sieben Kanäle: keys.openpgp.org, keyserver.ubuntu.com, pgpkeys.eu, WKD, sowohl über die Advanced-Methode via keys.openpgp.org als auch direkt selbst gehostet, der Download auf dem Blog, und der DANE-OPENPGPKEY-Record per Zone-Freeze/Thaw, DNSSEC-ad-Flag danach an drei öffentlichen Resolvern geprüft.

Eine gute Frage, die während des Rollouts aufkam: wenn WKD auf der eigenen Seite doch reicht, warum überhaupt noch auf die Keyserver hochladen, während gerade erst getestet wurde? Zwei Antworten. Erstens war der Test zu diesem Zeitpunkt schon abgeschlossen, der Ende-zu-Ende-Check lief gegen den lebenden Actor mit dem blanken Fingerprint, bevor der Schlüssel den Claim überhaupt trug, veröffentlicht wurde der Schlüssel erst, als das Ergebnis grün war. Zweitens ist WKD hier gar nicht wirklich nur die eigene Seite: die WKD-Advanced-Methode unter openpgpkey.kernel-error.com ist ein CNAME auf den WKD-Dienst von keys.openpgp.org, und Clients probieren Advanced zuerst. Auch das Keyoxide-Profil per Fingerprint holt sich den Schlüssel von keys.openpgp.org. Ohne das Keyserver-Update hätten die meisten Prüfer also weiterhin den alten Schlüssel mit vier Claims gesehen. Wahr und ebenfalls wichtig: Keyserver behalten jede Generation einer Selbstsignatur, es zählt nur die jüngste. Ein Claim lässt sich mit einer neueren Selbstsignatur zurückziehen, die alte Version verschwindet aber nie, das gehört vor der Veröffentlichung mitentschieden, nicht danach.

Das Ergebnis

Der Schlüssel abgerufen wie ein Fremder ihn abrufen würde, per HKP von keys.openpgp.org und per WKD, und mit doipjs gegen alle fünf Claims geprüft:

OK  200  wordpress  @kernel-error.de@www.kernel-error.de
OK  200  github     Kernel-Error
OK  200  dns        kernel-error.de
OK  200  dns        kernel-error.com
(Matrix braucht auf der Pruefer-Seite einen echten Matrix-Account, auf keyoxide.org gruen, in einem blanken lokalen Test nicht pruefbar)

Der Provider steht dabei als wordpress, doipjs liest den Software-Namen aus NodeInfo aus, bei diesem Blog tatsächlich {"software":{"name":"wordpress","version":"7.1"}}, live nachgeprüft. Die naheliegende erste Reaktion darauf war „müsste da nicht Mastodon stehen?“, die Antwort gehört in diesen Beitrag: der Account ist ein Fediverse-Account, kein Mastodon-Account, Mastodon ist nur eine von mehreren Implementierungen. Ein Account auf mastodon.social würde [mastodon] zeigen, einer auf einer GoToSocial-Instanz [gotosocial]. Man könnte den Namen in NodeInfo fälschen, das wäre aber falsch und nicht nur stilistisch: Server und Clients passen ihr Verhalten am Software-Namen aus, sie würden dann Mastodon-spezifische Endpunkte abfragen, die hier gar nicht existieren, und es wäre eine unwahre Aussage in einem Profil, dessen ganzer Zweck überprüfbare Echtheit ist.

Frische Schlüsselbunde über gpg --auto-key-locate clear,<methode> --locate-external-keys liefern für WKD, DANE und Keyserver jeweils alle fünf Claims. Größen mit fünf Claims: WKD direkt 13178 Byte, der volle Download 17926 Byte, der DANE-Record 1368 Byte, davon 70 Byte allein für diesen neuen Claim. Im Actor liegt attachment[0] jetzt auf PropertyValue OpenPGP, attachment[1] auf dessen Note-Begleitung, der Wert steht als reiner Text ohne umschließendes <p>, weil ein vorhandenes Code-Snippet auf dem Blog genau das aus Extra-Field-Werten entfernt, ursprünglich damit Mastodons rel="me"-Linkprüfung funktioniert, die einen nackten <a> als Wurzelelement erwartet. Zwei verschiedene Prüfer, zwei verschiedene Annahmen über die Form derselben Daten, an dieser einen Stelle trifft es genau zusammen.

Auf Mastodon selbst zeigt sich das neue Profilfeld erst, wenn entfernte Instanzen den Actor erneut abrufen, absichtlich wurde keine Update-Aktivität verschickt, das wäre wieder der Geister-Post-Mechanismus von oben. Keyoxide betrifft das nicht, das Werkzeug holt den Actor bei jeder Prüfung frisch.

Keyoxide-Profil mit fünf grünen Identity Claims, darunter der Fediverse-Actor @kernel-error.de@www.kernel-error.de mit dem Vermerk [wordpress].
Fünf von fünf, seit heute auch der Fediverse-Claim.

Lektionen

  • „Bei mir ist alles richtig“ kann wahr sein und trotzdem scheitern. Entscheidend sind die Annahmen des Verifizierers über die Form der Daten, nicht nur der Inhalt. Den Quelltext des Prüfers lesen, bevor man die eigene Seite zum fünften Mal durchsucht.
  • Eine Plugin-Funktion für bessere Zukunftskompatibilität, hier FEP-fb2a, bricht einen Verbraucher, der gegen Mastodons Datenform geschrieben wurde. Für sich genommen ist keiner der beiden im Unrecht.
  • Eine versteckte Abhängigkeit blieb übrig: der Beweis funktioniert nur, solange das OpenPGP-Feld das erste Profilfeld ist. Wer eigene Extra Fields verwaltet, sollte das im Kopf behalten.

Ein Upstream-Report an doipjs auf Codeberg ist noch nicht eingereicht. Der eigentlich richtige Fix wäre ein Try/Catch pro Array-Element in runJSON, das würde nebenbei auch die Abhängigkeit von der Feldreihenfolge auflösen.

Aufgeklappter Fediverse-Claim auf Keyoxide mit Profil-Link, Proof-Link und dem Hinweis, dass die Prüfung abgeschlossen ist und der Claim durch den Beweis bestätigt wurde.
Aufgeklappt: Profil-Link, Proof-Link, und die Prüfung ist durch.

Siehe auch

Selbst schon mal an einer Reihenfolge-Abhängigkeit zwischen zwei fremden Projekten verzweifelt, oder eigene Erfahrungen mit ActivityPub-Profilfeldern? Dann darfst du mich sehr gerne fragen.

BIOS erklärt, Teil 7: NUMA auf einer Maschine mit einem Sockel, und was Uncore eigentlich ist

Auf meiner alten Maschine standen zwei Prozessoren. Das klingt erst einmal nach doppelt so gut, und in Wahrheit war es vor allem doppelt so kompliziert. Ich habe damals ziemlich viel Zeit damit verbracht, einem Spiel beizubringen, dass es bitte auf der Hälfte der CPU laufen soll, und zwar auf derjenigen, an der auch die Grafikkarte hängt, weil der Weg zur anderen Hälfte zu lang war. Das Werkzeug dafür heisst NUMA, und wer einmal damit zu tun hatte, vergisst es nicht mehr.

Die neue Maschine hat einen Sockel. Damit ist das Thema erledigt, dachte ich. Ist es nicht ganz, und diese Folge erklärt warum. Es geht um Advanced > Chipset Configuration > North Bridge > Uncore Configuration und um zwei Zeilen aus den ACPI-Einstellungen. Alle Folgen dieser Serie stehen unter BIOS & Firmware.

Was Uncore überhaupt ist

Der Begriff kommt von Intel und meint alles auf dem Prozessorchip, was kein Rechenkern ist. Der gemeinsame L3-Cache. Die Speichercontroller. Die PCIe-Root-Ports. Das Verbindungsnetz, das all das miteinander verbindet. Bei einem modernen Xeon ist der Uncore flächenmässig ein erheblicher Teil des Chips und verbraucht auch einen erheblichen Teil des Stroms.

Der Uncore hat einen eigenen Takt, unabhängig von den Kernen. Er läuft weiter, wenn alle Kerne schlafen, denn irgendjemand muss ja mitbekommen, wenn eine Netzwerkkarte etwas will. Wenn man in Datenblättern liest, dass ein Serverprozessor im Leerlauf noch 40 Watt zieht, obwohl er angeblich nichts tut, ist das zum guten Teil der Uncore.

Die Kopfzeilen sagen mehr als der Rest

Aptio Setup, Uncore Configuration, mit Anzahl der Prozessoren, UPI-Status und den MMIO-Adressbereichen
Number of CPU                     1
Number of IIO                     1
Current UPI Link Speed            Slow or 1S Configuration
Current UPI Link Frequency        Unknown or 1S configuration

Diese vier Zeilen finde ich fast rührend. UPI ist Ultra Path Interconnect, Intels Verbindung zwischen zwei Prozessoren in einem System. Über sie laufen Speicherzugriffe auf den Speicher des jeweils anderen und die gesamte Cache-Kohärenz, also die Buchhaltung darüber, wer gerade welche Speicherzeile in seinem Cache liegen hat.

Bei mir steckt genau eine CPU im Board. Also gibt es niemanden, mit dem geredet werden müsste, und das Setup sagt das auf seine eigene Art: 1S Configuration, Single Socket. Die Firmware weiss es, das Menü existiert trotzdem, weil dasselbe BIOS auch auf Zwei-Sockel-Boards läuft.

Number of IIO ist die Zahl der Integrated IO Hubs, also der Einheiten, die die PCIe-Lanes bereitstellen. Bei einem Sockel logischerweise eins.

Interessanter sind die Adressbereiche darunter:

Global MMIO Low Base / Limit      90000000 / FBFFFFFF
Global MMIO High Base / Limit     0000200000000000 / 0000204FFFFFFFFF
PCIe Configuration Base / Size    80000000 / 10000000

Das ist die Speicherlandkarte für alles, was nicht RAM ist. Der untere Bereich liegt unterhalb von 4 GB, ist entsprechend eng und wird knapp, sobald mehrere Karten mit grossen Speicherfenstern stecken. Der obere beginnt bei 0x200000000000, also bei 32 Terabyte, und ist der Grund, warum meine Grafikkarte ihre kompletten 16 GB Videospeicher einblenden kann. Dazu mehr in der Folge über Above 4G Decoding und Resizable BAR.

NUMA, obwohl es nichts zu verteilen gibt

Aptio Setup, ACPI Settings, mit NUMA, UMA-Based Clustering im Hemisphere-Modus und WHEA Support auf Disabled

Zwei Menüs weiter, unter Advanced > ACPI Settings:

NUMA                              [Enabled]
UMA-Based Clustering              [Hemisphere (2-clusters)]

NUMA steht für Non-Uniform Memory Access und beschreibt den Umstand, dass nicht jeder Speicherzugriff gleich lange dauert. Bei zwei Sockeln ist das offensichtlich: der eigene Speicher ist nah, der des Nachbarn geht über UPI und kostet spürbar mehr Zeit. Das Betriebssystem muss davon wissen, sonst legt es Daten und die Prozesse die damit arbeiten in verschiedene Ecken der Maschine.

Bei einem Sockel gibt es diese Unterscheidung nicht, und mein System meldet folgerichtig:

lscpu | grep NUMA
#   NUMA-Knoten:            1
#   NUMA-Knoten0 CPU(s):    0-15

Ein Knoten, alle sechzehn logischen Prozessoren darin. Warum steht der Schalter dann auf Enabled? Weil er nicht nur Knoten erzeugt, sondern die ACPI-Tabellen füllt, in denen die Topologie beschrieben wird. Die stehen bei mir vollständig zur Verfügung:

ls /sys/firmware/acpi/tables/ | grep -E "SRAT|SLIT|HMAT"
#   HMAT  SLIT  SRAT

SRAT ordnet Prozessoren und Speicherbereiche einander zu, SLIT beschreibt die Entfernungen zwischen den Knoten, HMAT geht noch weiter und beschreibt Bandbreiten und Latenzen. Bei einem Knoten ist der Inhalt langweilig, aber vorhanden. Auf Disabled würde das Betriebssystem schlicht weniger über die Maschine wissen, ohne dafür irgendetwas zu gewinnen.

Der Schalter mit dem sperrigen Namen

UMA-Based Clustering [Hemisphere (2-clusters)] ist die Zeile, an der ich beim ersten Lesen hängen geblieben bin. Um sie zu verstehen, muss man wissen, wie so ein Chip innen aussieht.

Bei Ice Lake sitzen Kerne, Cache-Scheiben und Speichercontroller in einem Gitter, dem Mesh. Jeder Zugriff wandert durch dieses Gitter. Meine CPU hat vier Speichercontroller mit je zwei Kanälen, macht acht, und das ist genau die Zahl meiner Speichermodule. Je nachdem wo im Gitter ein Kern sitzt, ist der eine Controller näher als der andere.

Hemisphere teilt den Chip in zwei Bereiche und sorgt über eine Hash-Funktion dafür, dass eine Speicheradresse der Cache-Scheibe zugeordnet wird, die auf derselben Seite des Chips liegt wie der zuständige Speichercontroller. Der Weg durchs Gitter wird kürzer. Nach aussen bleibt es ein einziger Speicherbereich, das Betriebssystem merkt nichts davon. Die Variante Quadrant macht dasselbe mit vier Bereichen.

Passend dazu steht im Uncore-Menü:

SNC (Sub NUMA)                    [Disable]

Sub-NUMA-Clustering ist die radikale Variante derselben Idee. Es meldet dem Betriebssystem tatsächlich mehrere NUMA-Knoten, obwohl nur eine CPU steckt, und überlässt ihm die Verteilung. Für eine Datenbank, die weiss was sie tut, kann das etwas bringen. Für einen Desktop bringt es Ärger, weil plötzlich wieder Prozesse und ihr Speicher auseinanderlaufen können.

Ich habe mit genau diesem Problem auf der alten Maschine genug Abende verbracht. Das lasse ich aus, und zwar mit Nachdruck.

Die Prefetcher zwischen den Sockeln

Aptio Setup, Uncore Configuration weiter unten, mit XPT Prefetch, KTI Prefetch und Sub-NUMA-Clustering
XPT Prefetch                      [Auto]
XPT Remote Prefetch               [Auto]
KTI Prefetch                      [Auto]
Stale AtoS                        [Auto]
LLC Dead Line Alloc               [Enable]
IO Directory Cache (IODC)         [Auto]
Snoop Throttle Configuration      [Auto]
Local/Remote Threshold            [Auto]

In Teil 3 ging es um fünf Prefetcher, die raten welche Daten ein Kern als nächstes braucht. Das hier ist dieselbe Idee eine Ebene höher: raten, welchen Weg eine Anfrage nehmen wird, und sie schon mal losschicken.

XPT Prefetch schickt eine Speicheranfrage parallel zur Cache-Abfrage direkt an den Speichercontroller. Stellt sich heraus, dass die Daten doch im Cache lagen, war es umsonst. Stellt sich heraus, dass sie es nicht taten, hat man die Cache-Suche geschenkt bekommen. KTI Prefetch macht dasselbe über die Sockelverbindung hinweg, XPT Remote Prefetch ebenfalls, und beide sind bei einem Sockel gegenstandslos.

LLC Dead Line Alloc ist die einzige Zeile hier, die nicht auf Auto steht. Dahinter steckt eine Aufräumfrage: wenn eine Zeile aus dem L2 verdrängt wird, soll sie dann in den gemeinsamen L3 wandern, obwohl sie vermutlich tot ist, also nie wieder gebraucht wird? Enable heisst hier ja, und für allgemeine Lasten ist das die richtige Wette.

Snoop Throttle bremst die Kohärenzabfragen, wenn das Gitter überlastet ist. IO Directory Cache merkt sich, welche Cache-Zeilen von PCIe-Geräten angefasst wurden, damit nicht bei jedem DMA-Zugriff alle Kerne gefragt werden müssen.

Bei all diesen Zeilen gilt dasselbe: Auto bedeutet, dass Intel für die erkannte Konfiguration sinnvolle Werte hinterlegt hat. Wer hier von Hand dreht, sollte einen Messaufbau haben und eine konkrete Vermutung. Ich habe beides nicht, und ich glaube nicht, dass ich mit Raten schlauer bin als die Leute die den Chip entworfen haben.

Zwei Zeilen die ich fast überlesen hätte

Link L0p Enable                   [Disable]
Link L1 Enable                    [Disable]

Das sind Stromsparzustände für die UPI-Verbindung. L0p schaltet einen Teil der Leitungen ab, L1 die ganze Verbindung. Beide kosten Zeit beim Aufwachen, und beide sind hier ohne Wirkung, weil es keine UPI-Verbindung gibt.

Ich erwähne sie trotzdem, weil sie ein schönes Beispiel für etwas sind, das durch die ganze Serie zieht: ein Server-BIOS zeigt einem alles, was das Board könnte, nicht das was die konkrete Maschine hat. Wer hier ohne diese Einordnung durchgeht, stellt Dinge ein, die schlicht niemanden interessieren.

Fazit

Die Uncore-Seite ist die Seite, an der ich am wenigsten geändert habe und am meisten gelernt. Zwanzig Einstellungen, davon stehen sechzehn auf Auto, und das ist auch gut so. Was bleibt, ist ein ziemlich direkter Blick darauf, wie so ein Chip innen organisiert ist: ein Gitter, Speichercontroller an verschiedenen Stellen, Cache dazwischen, und eine Menge Mechanik die versucht, Wege abzukürzen.

Nächste Folge: PCIe. Slots, Lanes, was Bifurcation ist, und warum eine Grafikkarte in einem x16-Steckplatz trotzdem nur acht Bahnen nutzt.

Siehe auch

Falls jemand von euch SNC auf einem Ein-Sockel-Xeon eingeschaltet und dabei etwas Messbares gewonnen hat, würde ich das gerne lesen. Ihr dürft mich jederzeit fragen.

Keyoxide: die Linux-App ist im Flathub, dazu ein Android-Update und ein neuer Matrix-Kanal

Im August ging es hier darum, wie ich meinen OpenPGP-Schlüssel über Keyoxide an DNS, GitHub und Matrix gebunden habe. Seitdem hat sich beim Werkzeug selbst einiges getan, und das ist einen kurzen Nachtrag wert: es gibt jetzt eine Linux-App im Flathub, die Android-App wurde aktualisiert, und für Fragen rund um Keyoxide gibt es einen eigenen Matrix-Kanal.

Die Linux-App im Flathub

Bisher habe ich mein eigenes Profil und die meiner Kontakte immer im Browser aufgerufen. Jetzt gibt es dafür eine eigenständige Anwendung, installierbar direkt aus dem Flathub. Wichtig für die Einordnung: das ist ein Community-Paket, gepflegt vom Entwickler Berker und nicht offiziell vom Keyoxide-Projekt selbst herausgegeben, auf der Flathub-Seite steht das auch so. Unter der Haube steckt dieselbe Flutter-Codebasis wie in der Android-App, `keyoxide-flutter`.

Auf meinem Linux Mint installiert, sieht der Einstieg schlicht aus: ein Suchfeld für ein beliebiges Profil, und darunter die eigene Profilansicht mit allen bestätigten Identity-Claims.

Keyoxide-App unter Linux Mint als Flatpak, Startbildschirm mit dem Suchfeld für ein Profil.
Die Keyoxide-App aus dem Flathub, Startbildschirm mit Suchfeld.
Keyoxide-App zeigt das eigene Profil mit vier bestätigten Identity-Claims und dem öffentlichen Schlüssel über WKD.
Das eigene Profil in der App, alle vier Claims mit grünem Haken.

Inhaltlich ändert das nichts an dem, was im August-Beitrag steht, DNS, GitHub und Matrix bleiben bestätigt, aber eine native App ist für den täglichen Gebrauch angenehmer als jedes Mal die richtige URL zu tippen.

Das Android-Update

Parallel zur Linux-App wurde auch die Android-Version aktualisiert, Version 2.4.4 auf Codeberg. Gleicher Entwickler, gleiche Codebasis, zwei Plattformen aus einer Quelle.

Der QR-Code, einmal nachgemessen

Die App zeigt unter „Profile ID QR“ einen QR-Code fürs eigene Profil, darunter als Klartext den sogenannten Key Link, die WKD-Adresse des öffentlichen Schlüssels. Bevor der hier landet, wollte ich wissen, ob der Code wirklich auf die richtige Datei zeigt und nicht auf einen veralteten Stand.

Keyoxide-App zeigt den Profile-ID-QR-Code mit dem Key-Link zur WKD-Datei des öffentlichen Schlüssels.
Profile ID QR mit dem Key Link im Klartext darunter.

Zwei Dinge lassen sich hier unabhängig nachrechnen. Erstens der Pfad selbst: Web Key Directory bildet die Adresse aus einem SHA-1-Hash des lokalen Teils der E-Mail-Adresse, Z-Base-32-kodiert. Für kernel-error@kernel-error.com ergibt sha1("kernel-error"), Z-Base-32-kodiert, exakt 3gyjbxx9xfdggpkmx5qdd793xy431w5u, also genau der Wert im Key Link. Zweitens der Inhalt: die Datei unter diesem Pfad lässt sich herunterladen und mit gpg --show-keys auswerten, der Fingerabdruck darin ist 45FC D081 ADB5 4872 EA5B 06B9 893D E0CD DE98 6DEB, derselbe wie im Screenshot und derselbe wie der seit Juli aktuelle Schlüssel. Der QR-Code zeigt also tatsächlich auf die richtige, aktuelle Datei, nicht auf einen Cache-Stand oder eine falsche Kodierung.

Ein Matrix-Kanal für Fragen

Wer selbst mit Keyoxide anfangen will oder unterwegs hängen bleibt, findet jetzt einen eigenen Raum für allgemeine Fragen: #keyoxide:matrix.org. Ich lese dort mit.

Siehe auch

Eigene Erfahrungen mit Keyoxide, der Flatpak- oder der Android-App? Dann darfst du mich sehr gerne fragen.

QUIC auf Dual-Stack-Sockets: das DF-Bit, das nginx für IPv4-Clients nie setzt

nginx auf FreeBSD mit QUIC/HTTP/3: IPv6 ohne Router-Fragmentierung und IPv4 mit fehlendem DF-Bit auf einem Dual-Stack-Socket.

Ein Listener für IPv4 und IPv6 zusammen klingt nach der besseren Lösung. Ein Socket statt zwei, eine Konfigurationszeile weniger, ggf. ein Firewall-Regelwerk weniger zu pflegen. Bei QUIC auf diesem Blog lief das genau so, seit hier HTTP/3 aktiviert ist. Am 9. September 2026 ist mir aufgefallen, dass genau dieser eine Socket seit längerem dafür sorgt, dass nginx für die Hälfte der Clients ein Bit im IP-Header nie setzt, das QUIC eigentlich zwingend braucht. Kein Absturz, keine Fehlermeldung, kein Eintrag im Error-Log. Nur ein messbar höherer Anteil langsamer Verbindungen, der sich erst beim genauen Hinsehen als das entpuppt, was er ist.

Dieser Beitrag ist eine klassische Root-Cause-Analyse. Bug gefunden, Ursache im nginx-Quellcode verifiziert, Ursache zusätzlich im FreeBSD-Kernel nachvollzogen, gefixt, mit echten Produktionsdaten so gut wie möglich belegt.

Ein Socket für beides, das ist doch die naheliegende Wahl

Wer nginx mit QUIC betreibt, kennt die Direktive üblicherweise so:

listen [::]:443 quic reuseport ipv6only=off default_server;

Ein einziger IPv6-Socket mit ipv6only=off nimmt sowohl echte IPv6-Verbindungen als auch IPv4-Verbindungen an, dank IPv4-mapped-IPv6-Adressen (::ffff:a.b.c.d) nach RFC 3493. Ein Socket statt zwei, eine Zeile Konfiguration weniger. Genau deshalb ist das die naheliegende Wahl, wenn man von TCP kommt, wo dieser Trick seit Jahrzehnten problemlos funktioniert.

Bei QUIC ist genau dieser eine Socket ein Problem, und zwar eines, das man im laufenden Betrieb nicht sieht. Zumindest nicht sofort und nicht überall. Es müssen schon ein paar Dinge zusammen kommen um das Problem selbst zu fühlen. Fühlen? Eine lllaaaaannnngggggssssaaaaammmmeeeee Verbindung über IPv4.

Warum QUIC bei Fragmentierung empfindlicher ist als TCP über TLS

RFC 9000, Abschnitt 14, formuliert das ziemlich hart:

UDP datagrams MUST NOT be fragmented at the IP layer. In IPv4, the Don’t Fragment (DF) bit MUST be set if possible, to prevent fragmentation on the path.

Das ist keine Empfehlung, das ist ein MUST NOT direkt neben einem MUST. QUIC ermittelt seine eigene passende Paketgröße für einen Netzwerkpfad, entweder über klassisches PMTUD (das auf ICMP-Antworten angewiesen ist und auf der IP-Ebene ansetzt, das DF-Bit ist Teil genau dieses Mechanismus) oder über DPLPMTUD nach RFC 8899, das mit eigenen Sondierungspaketen arbeitet und ganz ohne ICMP auskommt. Wichtig ist hier nicht, welcher der beiden Mechanismen die Größe ermittelt, sondern was beide voraussetzen: ein zu großes Paket muss sauber verworfen werden, mit oder ohne ICMP-Antwort, statt fragmentiert beim Empfänger anzukommen. Wer sich jetzt an ICMPv6 für IPv6 erinnert fühlt, japp 🙂

Genau das geht in vielen Netzen schief, sobald ein Paket doch fragmentiert wird. Fragment-Filterung ist eine verbreitete Firmen- und Provider-Härtung, gerade weil fragmentierte Pakete historisch für allerlei Unfug missbraucht wurden. Solche Netze werfen fragmentierte QUIC-Pakete einfach weg, ohne jede Rückmeldung. QUICs eigene Verlusterkennung kann das von gewöhnlichem Paketverlust nicht unterscheiden, im schlechtesten Fall wird daraus eine Serie von Retransmissions und Timeouts statt eines schnellen, sauberen Fehlers. Bei TCP über TLS würde ein fragmentiertes Paket in den allermeisten Netzen einfach ankommen und zusammengesetzt werden, das ist dort seit jeher normaler Betrieb und niemanden juckt das DF-Bit besonders. Bei QUIC ist Fragmentierung der Fall, den das Protokoll bewusst ausschließen will, und genau der tritt hier auf.

Die Ursache, gefunden im nginx-Quellcode

Der Fund steckt in src/core/ngx_connection.c, in der Funktion ngx_configure_listening_sockets() (nicht zu verwechseln mit ngx_open_listening_sockets(), einer anderen Funktion in derselben Datei, die die Sockets öffnet, aber nicht die MTU-Discovery-Optionen setzt). Diese Funktion läuft beim Start und bei jedem Restart über alle bereits gebundenen Listen-Sockets und setzt dort die passenden Socket-Optionen. Der entscheidende Ausschnitt, wörtlich aus nginx 1.30.4:

#if (NGX_HAVE_IP_MTU_DISCOVER)

        if (ls[i].quic && ls[i].sockaddr->sa_family == AF_INET) {
            value = IP_PMTUDISC_DO;

            if (setsockopt(ls[i].fd, IPPROTO_IP, IP_MTU_DISCOVER,
                           (const void *) &value, sizeof(int))
                == -1)
            {
                ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
                              "setsockopt(IP_MTU_DISCOVER) "
                              "for %V failed, ignored",
                              &ls[i].addr_text);
            }
        }

#elif (NGX_HAVE_IP_DONTFRAG)

        if (ls[i].quic && ls[i].sockaddr->sa_family == AF_INET) {
            value = 1;

            if (setsockopt(ls[i].fd, IPPROTO_IP, IP_DONTFRAG,
                           (const void *) &value, sizeof(int))
                == -1)
            {
                ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
                              "setsockopt(IP_DONTFRAG) "
                              "for %V failed, ignored",
                              &ls[i].addr_text);
            }
        }

#endif

#if (NGX_HAVE_INET6)

#if (NGX_HAVE_IPV6_MTU_DISCOVER)

        if (ls[i].quic && ls[i].sockaddr->sa_family == AF_INET6) {
            value = IPV6_PMTUDISC_DO;

            if (setsockopt(ls[i].fd, IPPROTO_IPV6, IPV6_MTU_DISCOVER,
                           (const void *) &value, sizeof(int))
                == -1)
            {
                ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
                              "setsockopt(IPV6_MTU_DISCOVER) "
                              "for %V failed, ignored",
                              &ls[i].addr_text);
            }
        }

#elif (NGX_HAVE_IP_DONTFRAG)

        if (ls[i].quic && ls[i].sockaddr->sa_family == AF_INET6) {
            value = 1;

            if (setsockopt(ls[i].fd, IPPROTO_IPV6, IPV6_DONTFRAG,
                           (const void *) &value, sizeof(int))
                == -1)
            {
                ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
                              "setsockopt(IPV6_DONTFRAG) "
                              "for %V failed, ignored",
                              &ls[i].addr_text);
            }
        }

#endif
#endif

Beide Zweige prüfen ls[i].sockaddr->sa_family, also die Adressfamilie des Listen-Sockets selbst, fest zum Zeitpunkt des Socket-Setups. Nicht die Familie des Ziels, an das ein bestimmtes Paket später geht. Für listen [::]:443 quic ... ipv6only=off; ist sa_family immer AF_INET6, egal ob ein konkretes Paket an einen echten IPv6-Host oder an eine IPv4-mapped-Adresse geht. Der AF_INET-Zweig, der IP_DONTFRAG fürs echte IPv4-Datagramm setzen würde, wird für diesen Socket schlicht nie erreicht.

Ein kleines Detail für alle, die selbst nachlesen: der IPv6-Fallback-Zweig (das #elif (NGX_HAVE_IP_DONTFRAG) im letzten Block) ist mit demselben Feature-Makro abgesichert wie der IPv4-Zweig, nicht mit dem separat getesteten NGX_HAVE_IPV6_DONTFRAG. Auf FreeBSD ändert das am Ergebnis nichts, hier sind beide Makros gesetzt, aber es ist eine kleine Ungenauigkeit im nginx-Quellcode selbst. So zumindest meine Interpretation, korrigiert mich gerne!

Der eigentliche Denkfehler dahinter: IPv6 hat gar kein DF-Bit

Wer IPV6_DONTFRAG im Code sieht, könnte annehmen, dass damit dasselbe Problem für IPv6 gelöst wird wie IP_DONTFRAG für IPv4. Ist es nicht, und der Unterschied ist der eigentliche Kern dieses Bugs.

Der IPv6-Header hat schlicht kein DF-Bit, weil er keines braucht. RFC 8200 verbietet Routern im Pfad die Fragmentierung von IPv6-Paketen grundsätzlich, das steht wörtlich in Abschnitt 4.5 (der auf Abschnitt 5 verweist): „unlike IPv4, fragmentation in IPv6 is performed only by source nodes, not by routers along a packet’s delivery path“. IPV6_DONTFRAG ist die tatsächliche RFC-3542-Option, Abschnitt 11.2 der Spezifikation, aber sie steuert nur, ob der sendende Host selbst über einen Fragment-Extension-Header fragmentieren darf. Ein Problem, das aus Sicht von IPv6 gar nicht existiert, sobald man es nicht selbst provoziert.

IP_DONTFRAG auf der IPv4-Seite ist dagegen keine RFC-3542-Option, sondern eine BSD-Socket-Erweiterung außerhalb dieses Standards. Zwei Optionen, die im Code wie ein symmetrisches Paar aussehen, lösen in Wirklichkeit zwei völlig verschiedene Probleme.

Und jetzt der Punkt, an dem beide Welten aufeinandertreffen. Ein send() auf diesem IPv6-Socket an eine IPv4-mapped-Zieladresse bringt am Ende ein echtes IPv4-Datagramm auf die Leitung, das ist Standardverhalten von Dual-Stack-Sockets nach RFC 3493. Dieses Datagramm braucht ein gesetztes DF-Bit im IPv4-Header, ein komplett anderes Feld als alles, was IPV6_DONTFRAG je anfasst. Der oben gezeigte Code-Pfad kommt an dieses Feld nie heran, weil er für einen AF_INET6-Socket ausschließlich die IPv6-Variante der Option setzt. Kann man das also auch irgendwie ein legacy IPv4 Problem nennen?

Warum ein einfacher Zusatz-Aufruf auf FreeBSD gar nicht helfen würde

Die naheliegende Reaktion: warum ruft nginx auf diesem Socket nicht einfach zusätzlich setsockopt(IPPROTO_IP, IP_DONTFRAG) auf? Ich bin dem im FreeBSD-Kernel-Quellcode nachgegangen, und die Antwort ist, dass das auf FreeBSD gar nicht funktionieren würde.

Der Options-Handler für einen AF_INET6-Socket ist ip6_ctloutput() in sys/netinet6/ip6_output.c, und der lehnt Optionen außerhalb von IPPROTO_IPV6 rundweg ab:

level = sopt->sopt_level;
...
if (level != IPPROTO_IPV6) {
    error = EINVAL;
    ...
}

Ein setsockopt(fd, IPPROTO_IP, IP_DONTFRAG, ...) auf einem AF_INET6-Socket landet also mit EINVAL im Nichts. Das allein wäre schon Antwort genug, aber der zweite Teil ist genauso wichtig: wo genau setzt der Kernel das DF-Bit für einen IPv4-mapped-Send, der über einen IPv6-Socket rausgeht? In udp_usrreq.c, in der Funktion, die das eigentliche IPv4-UDP-Paket zusammenbaut:

if (inp->inp_flags & INP_DONTFRAG)
    ((struct ip *)ui)->ip_off |= htons(IP_DF);

Das DF-Bit wird nur gesetzt, wenn am PCB (dem Protocol Control Block der Verbindung) das Flag INP_DONTFRAG hängt. Und dieses Flag setzt ausschließlich ip_ctloutput() in ip_output.c, wenn IP_DONTFRAG auf IPPROTO_IP-Ebene erfolgreich gesetzt wurde, also genau der Aufruf, der auf einem AF_INET6-Socket mit EINVAL scheitert. Der IPv4-mapped-Sendepfad selbst führt übrigens tatsächlich über genau diese Funktion: udp6_send() in udp6_usrreq.c erkennt eine v4-mapped-Zieladresse auf einem Dual-Stack-Socket, gibt die eigenen Locks frei und ruft direkt udp_send(), also dieselbe IPv4-UDP-Ausgabefunktion, die auch ein echter AF_INET-Socket benutzt.

Damit schließt sich der Kreis / Beisst die Katze sich in den Schwanz, ihr wisst schon. INP_DONTFRAG kann nur über einen erfolgreichen IP_DONTFRAG-Aufruf auf einem echten AF_INET-Socket gesetzt werden, und ein AF_INET6-Socket kann diesen Aufruf laut ip6_ctloutput() gar nicht erst absetzen. Der Socket-Split ist auf FreeBSD damit nicht nur der pragmatische, sondern der einzig mögliche Fix. Ein hypothetischer nginx-Patch, der einfach den fehlenden setsockopt-Aufruf ergänzt, würde am Kernel selbst scheitern, bevor er überhaupt etwas bewirkt.

Was sich zwischen FreeBSD und Linux unterscheidet, und was nicht

Der Bug, wenn ich das so nennen darf, selbst sitzt im nginx-Quellcode und ist plattformunabhängig. Dieselbe sa_family-Abfrage wird auf Linux und FreeBSD gleich kompiliert und ist auf beiden gleichermaßen falsch.

Was sich unterscheidet, ist welche Socket-Option-API überhaupt zur Verfügung steht. IP_MTU_DISCOVER mit dem Wert IP_PMTUDISC_DO ist eine Linux-Erweiterung (auch musl bringt das Makro mit, nicht nur glibc), auf FreeBSD schlicht nicht deklariert. FreeBSD bietet stattdessen IP_DONTFRAG und IPV6_DONTFRAG. nginx testet im configure-Lauf beide Varianten (auto/unix, Feature-Tests für NGX_HAVE_IP_MTU_DISCOVER, NGX_HAVE_IP_DONTFRAG, NGX_HAVE_IPV6_DONTFRAG) und wählt zur Kompilierzeit die passende Variante. Auf dieser FreeBSD-Installation landet man im IP_DONTFRAG/IPV6_DONTFRAG-Zweig, und genau der ist von dem oben beschriebenen Bug betroffen.

Was ich nicht verifiziert habe, weil mir dafür ein Linux-Vergleichssystem fehlt (ich zu faul war eines aus dem Glas zu ziehen): ob sich das praktische Fehlerbild zwischen den beiden Kerneln tatsächlich unterscheidet, also wie stark Retransmissions ausfallen oder wie der jeweilige Kernel beim v4-mapped-Sendepfad mit dem DF-Äquivalent umgeht. Linux‘ PMTUD-Verhalten für genau diesen Fall könnte sich abweichend verhalten. Ehrliches Fazit an dieser Stelle: der nginx-Bug ist universell im Quellcode, das konkret beobachtete Fehlerbild ist hier ausschließlich für FreeBSD dokumentiert. Eine Aussage wie „genauso kaputt auf Linux“ würde ich ohne echten Beleg, also Paketmitschnitt oder Kernel-Quellcode-Analyse auf einem Linux-System, nicht treffen. Vielleicht mache ich mir die Mühe noch…. Vielleicht 😀

Der Fix: zwei Listener statt einem

Die Lösung ist, den einen Dual-Stack-Listener in zwei eigenständige Listener zu splitten, damit IPv4-Clients einen echten AF_INET-Socket bekommen, für den der obere Codepfad tatsächlich greift:

listen [::]:443 quic reuseport ipv6only=on default_server;
listen 443 quic reuseport default_server;

Ein Stolperstein dabei, den man sich leicht selbst stellt: ein einfaches zusätzliches listen 443 quic; neben einem unveränderten Dual-Stack-Socket kollidiert mit „Address already in use“, weil der alte Socket die IPv4-Wildcard-Adresse über ipv6only=off ja schon abdeckt. ipv6only muss auf dem IPv6-Listener also explizit auf on gesetzt werden, sonst gibt es keinen sauberen Split, sondern nur einen Fehlstart. Je nachdem, wie „ordentlich“ seine Konfiguration ist, kann es einen Moment dauern, alle diese Stellen zu finden, denn das muss für die komplette nginx Konfiguration angepasst sein!

Erster Stolperstein: ein reload reicht nicht

Eine reine ipv6only-Änderung an einem bestehenden QUIC-Socket erkennt nginx‘ Socket-Reuse-Logik bei einem reload nicht als neuen Socket. Bei mir blieb der alte Dual-Stack-Socket nach einem reinen reload laut sockstat weiter als udp46 aktiv, trotz fehlerfrei geprüfter Konfiguration (nginx -t) und trotz eines nginx -T-Dumps, der die neue Konfiguration bereits korrekt anzeigte. Ist bei solchen Dingen hin und wieder so, machen andere Dienste ja auch. Große Dinge wie Socket, wird oft nur bei einem echten Neustart vom Deinst gesetzt.

nginx -t prüft Syntax und versucht, referenzierte Dateien zu öffnen. Es sagt nichts darüber aus, ob ein bereits offener, laufender Listen-Socket bei einem Reload tatsächlich neu gebunden wird. Erst ein vollständiger service nginx restart hat die getrennten Sockets tatsächlich neu gebunden:

sockstat -4 -l | grep 443   # nur udp4
sockstat -6 -l | grep 443   # nur udp6

Nach dem Restart tauchte in der PROTO-Spalte ausschließlich udp4 und udp6 auf, kein einziges udp46 mehr auf Port 443. Das ist der eigentliche Beleg für den Reload-Stolperstein, keine Datei-Deskriptor-Analyse, nur der Blick auf einen laufenden Prozess. Das reiht sich in ein Muster ein, das mir auf dieser Infrastruktur schon öfter untergekommen ist: ein neues geladenes Modul, ein neuer server_name in einer bestehenden reuseport-Gruppe, jetzt ein ipv6only-Wechsel. Immer wieder dasselbe Grundmuster, ein Reload reicht bei bestimmten Änderungen an Listen-Sockets schlicht nicht. Aber ich wiederhole mich, hm?

Zweiter Stolperstein: jeder vHost braucht den Split

Der erste Durchlauf hat nur den Default-Server-Socket gesplittet. Alle anderen vHosts mit eigenem listen [::]:443 quic;, aber ohne eigenes listen 443 quic;, hingen danach nur noch am neuen IPv6-only-Socket. IPv4-QUIC-Verbindungen zu diesen Domains landeten beim Default-vHost, weil die SNI-Zuordnung auf dem neuen IPv4-Socket schlicht ins Leere lief. Sichtbar wurde das am falschen ausgelieferten TLS-Zertifikat und an einer Default-Antwort statt der eigentlich erwarteten.

Die Lehre daraus: bei einem reuseport-Split müssen alle Server-Blöcke der Adressgruppe angepasst werden, nicht nur der Default-Server. Der IPv4-Listener eines jeden weiteren vHosts docken danach ohne reuseport und ohne default_server einfach an die schon bestehende Gruppe an:

listen [::]:443 quic;
listen 443 quic;

Was im Error-Log nicht zu finden ist

Das nginx-Error-Log auf Level info, die komplette 15-Tage-Rotation samt .bz2-Archiven, enthält keine einzige [warn], [error] oder [crit]-Zeile mit QUIC-Bezug, weder vor noch nach dem Fix. Eine gezielte Suche nach den vier exakten Alert-Meldungen, die der oben zitierte Code beim Fehlschlagen eines setsockopt-Aufrufs loggen würde, kommt in der gesamten Historie null Mal vor. Vielleicht sollte ich das noch im Logging aufbohren, wenn möglich?!

Das ist methodisch wichtig: diese Alert-Meldungen enthalten das Wort „quic“ gar nicht. Ein reines grep -i quic im Error-Log hätte einen echten Fehlschlag dieser Aufrufe also gar nicht gefunden, selbst wenn es einen gegeben hätte. Erst die gezielte Suche nach dem exakten Meldungstext schließt aus, dass die Socket-Optionen fehlgeschlagen sind. Sie wurden erfolgreich gesetzt, nur eben die falsche Option für die falsche Adressfamilie. Kein Aufruf-Fehler, sondern genau der beschriebene Bug.

Warum das Error-Log strukturell nicht mehr hergibt: nginx sendet das UDP-Datagramm erfolgreich an den Kernel, die Fragmentierung passiert danach im Netz, an einem Router oder einer Middlebox, oder schon beim sendenden Host selbst, falls das ausgehende Interface-MTU überschritten wird. Davon bekommt der laufende nginx-Prozess in keinem Fall etwas mit. Wer nach diesem Bug im Error-Log sucht, wird nichts finden. Das ist erwartbar, nicht beruhigend, und ein blindes grep quic hätte selbst einen echten Konfigurationsfehler an dieser Stelle übersehen. Genau das ist der Punkt. Man sieht es am Server fast nur, wenn man auch einen tcpdump macht und auswertet. Denn die UDP Pakete gehen ja auf die Reise, sind sie nicht als „unzerteilbar“ markiert, kann und wird auf dem Weg aber bestimmt irgendein Router oder eine Firewall anfangen die Pakete klein zu hacken und dann ist es „kaputt“.

Verifikation über das Access-Log, und ihre Grenzen

nginx zeigt IPv4-Clients, die über einen Dual-Stack-Socket hereinkommen, im Access-Log als IPv4-mapped-Adresse (::ffff:a.b.c.d). Nach dem Fix kommen IPv4-QUIC-Clients über einen echten AF_INET-Socket herein und erscheinen als reine IPv4-Adresse ohne dieses Präfix. Diese Darstellung allein beweist schon, durch welchen Socket eine Verbindung lief, unabhängig von jeder Performance-Messung, aber eben auch unabhängig von jeder direkten Fragmentierungs-Messung.

Ausgewertet habe ich das Access-Log des Blogs (Format goa_ext, enthält $request_time), 13 Tage vor dem Fix plus den laufenden Tag danach, alle HTTP/3.0-Requests, aufgeteilt nach Adressdarstellung:

ZeitraumRequestsØ request_timeMaxAnteil über 1 s
vorher, IPv4 über kaputten Dual-Stack-Socket12.5370,461 s130,3 s5,33 %
nachher, IPv4 über eigenen udp4-Socket9460,037 s1,4 s0,42 %
Kontrollgruppe, natives IPv6, vorher17.3200,113 s67,3 s1,86 %
Kontrollgruppe, natives IPv6, nachher1.1680,082 s5,7 s2,14 %

Die betroffene IPv4-Gruppe lag vorher bei 5,33 Prozent über einer Sekunde, fast das Dreifache der IPv6-Baseline von 1,86 Prozent, und fällt nach dem Fix auf 0,42 Prozent, unter die IPv6-Baseline von 2,14 Prozent im selben Zeitraum. Genau dieser Kontrollgruppen-Vergleich macht die Zahlen für mich aussagekräftig, als Korrelation, die zur quellcode-basierten Root-Cause-Analyse passt, nicht als eigenständigen Fragmentierungsbeweis.

Eine zeitliche Punktprobe passt ins Bild: Restart am 9. September 2026 um 18:38:31 Uhr, letzter Request über den kaputten Dual-Stack-Pfad um 17:56:46 Uhr, erster Request über den neuen Pfad um 18:41:36 Uhr. Seit dem Restart lief keiner der 946 IPv4-HTTP/3.0-Requests mehr über die alte Darstellung. Das liegt plausibel am Restart-Zeitpunkt, mit einer Lücke ohne Traffic dieser Art von 42 Minuten davor und 3 Minuten danach bis zum ersten neuen Request, aber es ist kein exakter Sekundentreffer und ich beschreibe es auch nicht so.

Genauso wichtig wie die Zahlen sind für mich ihre Grenzen, deshalb ein eigener Absatz dafür statt eines Nebensatzes:

  • Ein fehlendes DF-Bit erlaubt Fragmentierung, es erzwingt sie nicht. Ein Datagramm, das auf jedem Hop unter dem dortigen MTU bleibt, ist unabhängig vom DF-Bit unproblematisch. Das kann zu Fragmentierung führen, es führt nicht automatisch dazu.
  • Die Adressdarstellung im Access-Log beweist, welchen Socket nginx verwendet hat. Sie sagt nichts über den DF-Bit-Zustand, die tatsächliche Paketgröße, die reale PMTU oder echte Fragmente auf der Leitung aus. Das gilt hier ohne Einschränkung, weil dieser vHost direkt am Internet hängt, ohne vorgeschaltete Proxy- oder Load-Balancer-Schicht, die $remote_addr umschreiben könnte.
  • Der Vergleich von 13 Tagen vorher gegen einen Teil eines Tages nachher ist kein kontrollierter Leistungsvergleich. Trafficmix, Client-Geografie, Cache-Zustand, Backend-Last und Tageszeit unterscheiden sich zwangsläufig. Die Zahlen sind ein starkes Indiz, kein experimenteller Beweis.
  • Die IPv6-Kontrollgruppe ist nicht vollständig unverändert geblieben. Nur der Anteil über einer Sekunde bleibt in vergleichbarer Größenordnung. Mittelwert und Maximum verschieben sich durchaus, vermutlich schlicht durch allgemein geringere Last oder einen anderen Trafficmix zum jeweiligen Zeitpunkt.
  • Eine Retransmission-Spirale statt eines schnellen Fails ist ein plausibles, aber kein garantiertes Symptom. Wie sich ein verlorenes, fragmentiertes QUIC-Paket konkret auswirkt, hängt von der Verlusterkennung und der PMTU-Implementierung der beteiligten QUIC-Stacks ab, auf Client- wie auf Serverseite.
  • Ein wirklich abschließender Beweis bräuchte einen Vorher- und Nachher-Paketmitschnitt, der das DF-Bit direkt zeigt, im Idealfall mit einem kontrollierten Test gegen ein reduziertes MTU. Ohne den betrachte ich die Produktionszahlen als starke, durch eine Kontrollgruppe abgesicherte Korrelation, die zur quellcode-basierten Root-Cause-Analyse passt, nicht als direkten Fragmentierungsbeweis. Ein Mitschnitt von der Vorher-Seite liegt inzwischen vor, dazu der nächste Abschnitt.

Ein Paketmitschnitt vom Tag vor dem Fix schließt einen Teil der Lücke

Nachtrag vom 12. September 2026: Der vorherige Abschnitt musste ohne einen echten Paketmitschnitt auskommen. Der liegt inzwischen vor. Zwei rund 41 Sekunden lange Aufzeichnungen vom 8. September 2026, einen Tag vor dem Fix, eine direkt auf dem Server, die andere gleichzeitig auf einem Notebook, das die Seite über ein anderes Netz aufgerufen hat. Beide zusammen zeigen den echten HTTP/3-Verkehr eines einzelnen Seitenaufrufs.

Ausgewertet mit tcpdump -r <Datei> -n -v, ein tshark stand mir nicht zur Verfügung. Jedes Paket trägt im IP-Header ein Identification-Feld, und das ist in beiden Mitschnitten innerhalb des Beobachtungsfensters durchgehend eindeutig, keine einzige Dopplung unter den mehr als dreitausend vom Server verschickten Paketen. Darüber lassen sich einzelne Datagramme zwischen den beiden Aufzeichnungen zweifelsfrei einander zuordnen, nicht nur über Summen vergleichen.

RichtungBeim Server verschicktBeim Notebook angekommenAbgleich
Notebook zu Server1.147, alle mit DF-Bit1.147, alle mit DF-Bitidentisch, per ID geprüft
Server zu Notebook, klein genug fürs MTU898, kein DF-Bit898, dieselben IDs0 Prozent Verlust
Server zu Notebook, fragmentiert, kein DF-Bit2.162 erste Fragmente878, per ID zugeordnet1.284 fehlen, 59,4 Prozent

Kein einziges der 1.147 Pakete vom Notebook zum Server fehlt das DF-Bit, kein einziges der 3.060 Pakete in der Gegenrichtung trägt eines. Das bestätigt den Fund aus dem Quellcode jetzt direkt auf der Leitung, nicht nur über die Adressdarstellung im Access-Log. 2.162 dieser Server-Pakete sind schon beim Verlassen des Servers fragmentiert, jedes erste Fragment exakt 1500 Byte lang, also an der Ethernet-MTU gekappt. Von diesen 2.162 erscheinen im Notebook-Mitschnitt nur 878 wieder, eins zu eins über die ID zugeordnet. 1.284 fehlen komplett, das sind 59,4 Prozent. Die 898 kleinen, unfragmentierten Antworten kommen dagegen alle an, ebenfalls per ID geprüft und nicht bloß über die Summe verglichen. Über die ganzen 41 Sekunden verteilt liegt der fehlende Anteil in jedem Fünf-Sekunden-Fenster zwischen 51 und 68 Prozent. Kein Ausreißer am Anfang oder Ende der Aufzeichnung, der auf einen Zeitversatz zwischen den beiden Mitschnitten hindeuten würde.

Die zweite Hälfte jedes fragmentierten Datagramms taucht in keinem der beiden Mitschnitte auf. Vermutlich wurde mit einem Filter auf den UDP-Port aufgezeichnet, und ein IP-Fragment ohne den ersten Teil trägt keinen UDP-Header, an dem so ein Filter ansetzen könnte. Das lässt offen, ob einzelne der 878 angekommenen ersten Fragmente trotzdem nie vollständig zusammengesetzt werden konnten, weil ihre zweite Hälfte fehlte. 59,4 Prozent sind deshalb eine gemessene Untergrenze für den tatsächlichen Verlust, keine Obergrenze. Und es bleibt eine einzelne Sitzung, ein Client, ein Pfad, eine Zeitspanne von 41 Sekunden, kein Beleg für eine feste Verlustquote im ganzen Netz.

So sieht der Unterschied im Rohformat aus, Adressen durch Platzhalter ersetzt:

12:01:29.758438 IP (id 0, offset 0, flags [DF], length 1280)
    CLIENT.35577 > SERVER.443: UDP, length 1252
12:01:29.771757 IP (id 51997, offset 0, flags [+], length 1500)
    SERVER.443 > CLIENT.35577: UDP, length 1480

Dieser Mitschnitt stammt vom Tag vor dem Fix, ein gepaarter Mitschnitt von danach existiert aus dem echten Betrieb nicht. Der Versuch direkt nach dem Fix blieb leer, weil der dafür verwendete Client kein echtes HTTP/3 sprach und der Mitschnitt dadurch keinen QUIC-Verkehr enthielt. Die Vorher-Nachher-Zahlen aus dem Access-Log oben bleiben deshalb weiterhin die Grundlage für den gemessenen Effekt des Fixes. Was dieser Mitschnitt zusätzlich liefert, ist der direkte Beleg für den Mechanismus dahinter: das fehlende DF-Bit und die Fragmentierung selbst sind jetzt nicht mehr nur aus dem Quellcode abgeleitet, sondern auf der Leitung beobachtet.

Fazit

Ein einzelner Dual-Stack-Socket für QUIC ist offenbar kein Einzelfall, sondern eher ein wiederkehrendes Footgun-Muster über verschiedene Implementierungen hinweg. quic-go, die von Marten Seemann entwickelte QUIC-Bibliothek, musste DPLPMTUD auf älteren macOS-Versionen abschalten, weil sich das DF-Bit auf einem Dual-Stack-Socket dort schlicht nicht setzen ließ, und dokumentiert das offen. Ein separates Issue im selben Projekt beschreibt ein anderes, aber verwandtes Dual-Stack-Problem gerade auf FreeBSD (fehlgeschlagenes sendmsg für IPv4-mapped-Adressen), mit einem Verweis auf FreeBSDs eigene inet6(4)-Manpage, die für AF_INET6-Sockets ganz grundsätzlich empfiehlt, zwei getrennte Sockets zu verwenden, aus Sicherheitsgründen, unabhängig von QUIC. Die Empfehlung, IPv4 und IPv6 getrennt zu halten, existierte auf dieser Plattform also schon, bevor QUIC überhaupt erfunden wurde. Für den konkreten Fall hier heißt das, dass der Socket-Split kein Workaround für einen einzelnen nginx-Bug ist, sondern die Rückkehr zu der Architektur, die FreeBSD für genau diese Situation ohnehin vorsieht.

Ein Socket für beides bleibt bei TCP über TLS ein legitimer, funktionierender Kompromiss. Bei QUIC ist er, zumindest auf dieser Kombination aus nginx und FreeBSD, ein stiller Bug, der sich weder im Error-Log noch im laufenden Betrieb meldet, sondern nur über einen erhöhten Anteil langsamer Verbindungen bemerkbar macht. Wer selbst QUIC auf einem Dual-Stack-Listener betreibt, findet die Antwort auf die Frage, welchen Socket ein Client gerade benutzt, mit dem Access-Log-Trick oben in wenigen Minuten selbst heraus.

Siehe auch:

Fragen, Einwände oder eigene Erfahrungen mit QUIC auf Dual-Stack-Sockets? Dann darfst du mich sehr gerne fragen.

BIOS erklärt, Teil 6: 256 GB ECC, ein Scrubber der jeden Tag putzt und ein Reparaturmechanismus im Speicherriegel

In dieser Folge geht es um Advanced > Chipset Configuration > North Bridge > Memory Configuration. Wer die Serie bis hierhin nicht mitgelesen hat: alle Teile liegen unter BIOS & Firmware, und es lohnt sich nicht, sie in der Reihenfolge zu lesen. Jede Folge steht für sich.

Was verbaut ist

Aptio Setup, Memory Topology, alle acht Module mit 2933 Megatransfers und 32 Gigabyte als Registered DIMM
DIMMA1: 2933MT/s ATP DRx4 32GB RDIMM
DIMMB1: 2933MT/s ATP DRx4 32GB RDIMM
DIMMC1: 2933MT/s ATP DRx4 32GB RDIMM
DIMMD1: 2933MT/s ATP DRx4 32GB RDIMM
DIMME1: 2933MT/s ATP DRx4 32GB RDIMM
DIMMF1: 2933MT/s ATP DRx4 32GB RDIMM
DIMMG1: 2933MT/s ATP DRx4 32GB RDIMM
DIMMH1: 2933MT/s ATP DRx4 32GB RDIMM

Acht Steckplätze, acht Module, jedes 32 GB. Das ist die Seite Memory Topology, und sie sagt in acht Zeilen mehr über den Aufbau der Maschine als jedes Datenblatt.

RDIMM heisst Registered DIMM. Zwischen dem Speichercontroller und den eigentlichen Speicherchips sitzt ein Registerbaustein, der Adress- und Steuersignale zwischenspeichert und neu ausgibt. Der Grund ist elektrisch: je mehr Chips an einem Kanal hängen, desto stärker belastet das die Signalleitungen. Der Registerbaustein bricht diese Last auf. Deshalb gibt es in Servern acht Module pro Sockel und in Desktops vier, und deshalb kann man normale Desktop-Riegel hier nicht einsetzen. Der Preis ist ein Takt Latenz zusätzlich.

DRx4 beschreibt den Aufbau: Dual Rank, jeder Chip liefert 4 Bit. Zwei Ranks bedeutet, dass auf dem Modul zwei unabhängige Gruppen von Chips sitzen, zwischen denen der Controller umschalten kann. Während eine Gruppe noch mit einer Anfrage beschäftigt ist, kann er die andere schon ansprechen, was den Durchsatz verbessert.

Und 2933MT/s, obwohl auf den Modulen 3200 steht. Das ist keine Fehlfunktion, sondern die CPU. Der Xeon Gold 5315Y ist die Einstiegsvariante der Ice-Lake-Serie, und Intel hat den Speichertakt an die Prozessorstufe gekoppelt. Die teureren Modelle fahren 3200, meiner eben 2933. Die Riegel könnten mehr, sie dürfen nicht. Genau so meldet es auch das Betriebssystem:

dmidecode -t memory | grep -E "Speed|Configured"
#   Speed: 3200 MT/s
#   Configured Memory Speed: 2933 MT/s

Was das Modul kann, und was es tatsächlich tut. Der Unterschied sind rund acht Prozent Speicherbandbreite, die ich nicht bekomme. Verschmerzbar.

ECC, der Teil den alle kennen

Erstaunlicherweise gibt es auf dieser Seite gar keinen Schalter namens ECC. Bei registrierten Servermodulen ist Fehlerkorrektur keine Option, sondern eine Eigenschaft der Hardware. Sie ist da, sie ist an, fertig.

Falls es jemand nicht parat hat: ECC speichert zu je 64 Bit Nutzdaten 8 zusätzliche Prüfbits. Damit lässt sich ein gekipptes Bit erkennen und reparieren, und zwei gekippte Bits lassen sich zumindest erkennen. Das ist der Unterschied zwischen „im Log steht eine korrigierte Zeile“ und „irgendwo in einer Datei steht jetzt ein falsches Byte, viel Spass beim Suchen“.

Bits kippen häufiger als man denkt. Kosmische Strahlung, Alphateilchen aus dem Gehäusematerial, schlicht Alterung. Bei 256 GB, die dauerhaft laufen, ist das keine theoretische Grösse mehr.

Patrol Scrub, und die Zahl aus dem Handbuch

Patrol Scrub                      [Enable at End of POST]

Das ist mein Lieblingsschalter auf dieser Seite. ECC korrigiert nämlich nur dann, wenn jemand die betroffene Speicherstelle liest. Ein Bit in einem Speicherbereich, den seit drei Wochen niemand angefasst hat, kippt unbemerkt. Kippt in derselben Zeile ein zweites, ist der Fehler nicht mehr korrigierbar.

Patrol Scrub geht deshalb im Hintergrund den gesamten Speicher durch, liest jede Zeile, lässt ECC prüfen und schreibt sie korrigiert zurück. Vorbeugendes Putzen, damit sich Einzelfehler nicht zu Doppelfehlern summieren.

Das Handbuch nennt dazu eine Zahl, die man sonst nirgends findet:

the IO hub reads and writes back one cache line every 16K cycles … roughly 64 GB of memory behind the IO hub is scrubbed every day

Eine Cache-Zeile alle 16.000 Takte, macht etwa 64 GB pro Tag. Bei meinen 256 GB dauert ein kompletter Durchlauf also rund vier Tage. Das klingt langsam, ist aber genau der Punkt: der Scrubber soll im Hintergrund verschwinden und nicht die Speicherbandbreite auffressen, die eigentlich für Arbeit gedacht ist.

Enable at End of POST heisst, dass er nach dem Selbsttest startet, also bevor das Betriebssystem läuft, und dann durchgehend weiterarbeitet.

PPR: der Reparaturmechanismus im Riegel

Aptio Setup, Memory Configuration, mit Enhanced PPR, PPR Type, Speichertakt und den Untermenüs für Topologie und RAS
Enhanced PPR                      [Disable]
PPR Type                          [Hard PPR]
Enforce POR                       [POR]

PPR steht für Post Package Repair, und dahinter steckt etwas, das mich beim Nachlesen ehrlich überrascht hat: moderne DDR4-Module haben Ersatzzeilen eingebaut. Fällt eine Speicherzeile dauerhaft aus, kann die Firmware sie stilllegen und eine Reservezeile an ihre Stelle setzen. Der Riegel repariert sich also selbst, im laufenden Betrieb einer Maschine, ohne dass ihn jemand anfasst.

Hard PPR bedeutet, dass diese Umleitung dauerhaft in den Riegel gebrannt wird und einen Neustart übersteht. Die Alternative Soft PPR gilt nur bis zum nächsten Ausschalten. Hart ist sinnvoller, kostet aber eine der wenigen Reservezeilen unwiderruflich.

Enforce POR steht für Plan of Record und ist die Frage, ob das Board die von Intel freigegebenen Speicherparameter erzwingt oder ob es auch aggressivere Kombinationen zulässt. Auf einem Serverboard mit 256 GB ECC ist die Antwort selbstverständlich ja.

Data Scrambling for DDR4 [Enable] weiter unten macht etwas ganz anderes, als der Name vermuten lässt. Das ist keine Verschlüsselung. Die Daten werden vor dem Schreiben mit einem Pseudozufallsmuster verwürfelt, damit auf den Datenleitungen keine langen Folgen identischer Bits entstehen. Solche Muster erzeugen Störungen auf den Nachbarleitungen und einen unruhigen Stromverbrauch. Verwürfeln macht das Signalbild gleichmässiger. Wer glaubt, sein Speicher sei damit verschlüsselt, irrt sich, dafür wäre TME zuständig, und über den habe ich in Teil 4 geschrieben.

Memory RAS: das Menü das kleiner ist als erwartet

Aptio Setup, Memory RAS Configuration, mit Mirror Mode auf Disabled und der Schwelle für korrigierbare Fehler
Memory RAS Configuration Setup
Enable Pcode WA for SAI PG        [Disabled]
Mirror Mode                       [Disabled]
UEFI ARM Mirror                   [Disabled]
Correctable Error Threshold       512

RAS steht für Reliability, Availability, Serviceability, und ich hatte hier ehrlich gesagt mehr erwartet. In Intel-Dokumentation tauchen an dieser Stelle regelmässig Rank Sparing, ADDDC Sparing und PCLS auf. Auf diesem Board gibt es davon nichts, die Seite hat genau diese vier Zeilen und keinen Rollbalken.

Mirror Mode ist die interessanteste Option, gerade weil sie aus ist. Speicherspiegelung schreibt jeden Wert doppelt, auf zwei verschiedene Kanäle. Fällt eine Stelle dauerhaft aus, übernimmt die Kopie, ohne dass die Maschine stehenbleibt. Der Preis ist die Hälfte des Speichers: aus 256 GB würden 128.

Für einen Datenbankserver, bei dem eine Stunde Ausfall Geld kostet, kann das die richtige Rechnung sein. Für meine Workstation nicht. ECC fängt Einzelfehler ohnehin ab, Patrol Scrub verhindert dass sie sich anhäufen, und wenn wirklich ein Modul stirbt, tausche ich es. 128 GB zu verschenken, um mir einen Neustart zu ersparen, ist mir zu teuer.

Correctable Error Threshold 512 ist die Schwelle, ab der die Firmware eine Speicherstelle als auffällig meldet. Einzelne korrigierte Fehler passieren, das ist Normalbetrieb. 512 an derselben Stelle sind ein Muster.

Die Gegenprobe, und warum ich sie erst nachrüsten musste

An dieser Stelle kommt der Teil, bei dem ich mich selbst ertappt habe. Der ganze Aufwand oben, ECC, Scrubber, PPR, Fehlerschwellen, läuft ins Leere, wenn niemand hinsieht. Linux erfasst Speicherfehler über das EDAC-Subsystem, und das war bei mir aktiv:

ls /sys/devices/system/edac/mc/
#   mc0  mc1  mc2  mc3

Vier Speichercontroller, alle angebunden. Nur landeten die Ereignisse ausschliesslich im Kernel-Ringpuffer, und der ist nach einem Neustart leer. Ich hätte also gemerkt, wenn gerade etwas passiert. Ich hätte nie gemerkt, dass ein bestimmter Riegel seit Wochen langsam schlechter wird. Genau das ist aber der Fall, für den man ECC überhaupt haben will.

Nachgerüstet ist das mit einem einzigen Paket:

apt-get install rasdaemon
ras-mc-ctl --error-count
#   CPU_SrcID#0_MC#0_Chan#0_DIMM#0   CE 0   UE 0
#   ... (acht Zeilen)
ras-mc-ctl --summary
#   No Memory errors.

rasdaemon schreibt die Ereignisse in eine SQLite-Datenbank und führt sie pro Modul. CE sind korrigierte Fehler, UE nicht korrigierbare. Bei mir stehen beide auf null, und jetzt weiss ich das auch für die Vergangenheit und nicht nur für diesen Bootvorgang.

Ein Wermutstropfen: die acht Zähler heissen MC#0_Chan#0_DIMM#0 und nicht DIMMA1. Das Paket bringt für dieses Board keine Zuordnungstabelle mit. Ich könnte eine schreiben, die Reihenfolge liegt nahe, aber ich habe es bewusst gelassen. Ein falsches Etikett ist schlimmer als gar keins, weil es einen im Fehlerfall an den falschen Steckplatz schickt.

Nächste Folge: NUMA auf einer Maschine mit genau einem Sockel, warum das trotzdem ein Thema ist, und was Uncore und UPI eigentlich sind.

Siehe auch

Falls jemand von euch schon einmal ein PPR-Ereignis in freier Wildbahn gesehen hat, also einen Riegel der sich tatsächlich selbst repariert hat: das würde ich gerne hören. Ihr dürft mich jederzeit fragen.

BIOS erklärt, Teil 5: SpeedStep, Turbo, C-States und der Schalter der mir eine Woche Rätselraten erspart hätte

Willkommen bei Teil 5. Die bisherigen Folgen findest du gesammelt unter BIOS & Firmware. Heute geht es um Advanced > CPU Configuration > Advanced Power Management Configuration.

Advanced

Aptio Setup, Advanced Power Management Configuration, mit Power Technology auf Custom und sechs Untermenüs

Die Seite selbst ist kurz:

Power Technology                  [Custom]
Power Performance Tuning          [OS Controls EPB]
ENERGY_PERF_BIAS_CFG Mode         [Balanced Performance]
> CPU P State Control
> Hardware PM State Control
> Frequency Prioritization
> CPU C State Control
> Package C State Control
> CPU T State Control

Die erste Zeile ist wichtiger als sie aussieht. Power Technology kennt neben Custom noch Disable und Energy Efficient, und solange sie nicht auf Custom steht, sind die sechs Untermenüs Dekoration: die Firmware setzt dann ihre eigenen Werte und überschreibt, was man darunter einstellt. Wer sich also wundert, warum eine Änderung im C-State-Menü nichts bewirkt, sollte zuerst hier nachsehen.

Power Performance Tuning auf OS Controls EPB heisst, dass das Betriebssystem den Energy Performance Bias steuern darf und nicht das BIOS. Das ist genau das, was man auf einer Linux-Maschine will. Der dritte Wert ENERGY_PERF_BIAS_CFG Mode ist der Vorgabewert, den die Firmware setzt, solange sich niemand darum kümmert, und Balanced Performance ist dafür eine vernünftige Mitte.

P-States: die Frequenz

Aptio Setup, CPU P State Control, mit SpeedStep, Turbo Mode und der Tabelle der Speed-Select-Profile
SpeedStep (P-States)              [Enable]
AVX P1                            [Nominal]
Dynamic SST-PP                    [Disable]
Intel SST-PP                      [Base]
Activate SST-BF                   [Disable]
Configure SST-BF                  [Enable]
EIST PSD Function                 [HW_ALL]
Turbo Mode                        [Enable]
CPU Flex Ratio Override           [Disable]
CPU Core Flex Ratio               23

SpeedStep ist der Klassiker: die CPU darf ihre Frequenz und Spannung an die Last anpassen. Intel nennt das seit Ewigkeiten EIST, Enhanced Intel SpeedStep Technology. Ohne diesen Schalter läuft der Prozessor stur auf Basistakt, verbraucht im Leerlauf deutlich mehr und wird wärmer, ohne dafür irgendetwas zurückzugeben. Der bleibt an.

Turbo Mode ist die Gegenrichtung: die CPU darf über den Basistakt hinaus, solange Temperatur und Stromaufnahme es hergeben. Bei meinem Xeon Gold 5315Y sind das 3,2 GHz Basis und 3,6 GHz Turbo. Linux bestätigt beide Enden:

lscpu | grep MHz
#   CPU max MHz:  3600,0000
#   CPU min MHz:   800,0000

EIST PSD Function auf HW_ALL regelt, wer die Frequenzwechsel koordiniert. Bei HW_ALL macht das die Hardware für alle Kerne einer Domäne selbst, bei SW_ALL müsste das Betriebssystem mitreden. Hardware ist hier schneller und weiss ohnehin mehr, das lässt man so. ALso ich, öhm. joar. 😀

Der Teil der mich überrascht hat: Speed Select

Mitten auf der Seite steht eine kleine Tabelle, die ich vorher noch nie in einem BIOS gesehen habe:

Intel SST-PP            Base | Config 3 | Config 4
  Core Count             08  |    06    |    04
  Current P1 Ratio [0]   32  |    32    |    34
  Package TDP (W)       140  |   125    |   115
  Tjmax                 103  |   100    |   101

Das ist Intel Speed Select Technology, Variante Performance Profile. Die Idee dahinter: dieselbe CPU kann in mehreren fest definierten Betriebspunkten laufen, und man wählt beim Booten aus, welchen man haben möchte. Meine hat drei. Im Auslieferungszustand Base sind es acht Kerne bei 3,2 GHz und 140 Watt. Wählt man Config 4, bleiben vier Kerne übrig, dafür steigt der Basistakt auf 3,4 GHz und die Verlustleistung fällt auf 115 Watt.

Man tauscht also Kerne gegen Takt, und zwar nicht per Turbo-Zufall, sondern als garantierte Zusicherung. Für Software, die pro Kern lizenziert wird, ist das bares Geld. Für Lasten, die nicht parallelisieren, sind 200 MHz mehr Basistakt spürbar. Und für mich? Ehrlich gesagt nutzlos. Ich habe acht Kerne gekauft, weil ich acht Kerne wollte, und 200 MHz sind kein Argument dafür, die Hälfte davon wegzuwerfen.

Trotzdem finde ich es bemerkenswert, dass so etwas in einem BIOS steht, das ansonsten aussieht wie 2005. SST-BF daneben ist die verwandte Idee auf Kernebene: Base Frequency, einzelne Kerne bekommen dauerhaft mehr Takt, die übrigen entsprechend weniger. Steht bei mir auf Disable, und das passt, denn dafür müsste ich meine Last gezielt auf die schnellen Kerne pinnen. Wer das tut, weiss warum. Wer es nicht tut, gewinnt nichts.

Und hier lag die Antwort

Aptio Setup, Hardware PM State Control, Hardware P-States steht auf Disable
Hardware PM State Control
Hardware P-States                 [Disable]

Ein Untermenü mit genau einer Zeile, und diese Zeile hat mich mehr gefreut als der ganze Rest der Seite. Um zu erklären warum, muss ich kurz ausholen.

Seit dem Umbau ist mir aufgefallen, dass Linux die Frequenzsteuerung auf dieser Maschine anders macht als erwartet. intel_pstate, der Treiber der bei Intel-CPUs normalerweise das Kommando übernimmt, läuft hier im passiven Modus:

cat /sys/devices/system/cpu/intel_pstate/status        # passive
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_driver # intel_cpufreq
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor # schedutil

Passiv bedeutet: der Treiber verhält sich wie ein gewöhnlicher cpufreq-Treiber und lässt den Kernel-Governor entscheiden. Aktiv bedeutet, dass er die Entscheidung selbst trifft und dabei Hardware-Unterstützung nutzt. Aktiv ist normalerweise der bessere Modus, weil die CPU schneller reagiert als jeder Governor es könnte.

Der Kernel schaltet in den aktiven Modus, wenn die CPU HWP beherrscht, Hardware P-States. Ice Lake beherrscht das. Nur taucht das entsprechende Flag bei mir gar nicht auf:

grep -o hwp /proc/cpuinfo | head -1
#   (keine Ausgabe)

Ich hatte das eine Weile für eine Eigenheit des Kernels gehalten, für eine Frage der Xeon-Variante, für irgendetwas Kompliziertes. Es war nichts davon. Es war dieser eine Schalter, zwei Menüebenen tief in einem Untermenü, das ich beim ersten Mal nicht geöffnet hatte. Ich sag doch, ich bin auch nur doof.

Was tut HWP eigentlich? Ohne HWP sagt das Betriebssystem der CPU, welche Frequenz sie fahren soll. Mit HWP sagt es ihr nur noch, worauf es ihm ankommt, irgendwo zwischen Sparsamkeit und Leistung, und die CPU sucht sich den Rest selbst. Der Vorteil ist die Reaktionszeit: die Hardware sieht Lastwechsel im Mikrosekundenbereich, der Governor im Millisekundenbereich. Bei kurzen, stossweisen Lasten, wie sie ein Desktop dauernd produziert, macht das einen Unterschied.

Werde ich es umstellen? Vermutlich ja, aber nicht heute und nicht ohne Messung. Auf der alten Maschine hatte ich Ärger mit schedutil, der mir zu träge hochgetaktet hat, insofern habe ich eine Vermutung wohin es geht. Aber genau solche Vermutungen sind der Grund, warum man am Ende Dinge kaputtoptimiert. Erst messen, dann drehen.

C-States: das Nichtstun

Aptio Setup, CPU C State Control, mit Monitor MWAIT, C6 Report und Enhanced Halt State
Enable Monitor MWAIT              [Enable]
CPU C6 Report                     [Auto]
Enhanced Halt State (C1E)         [Enable]

P-States regeln, wie schnell die CPU arbeitet. C-States regeln, wie tief sie schläft wenn sie gerade nichts tut. Je höher die Nummer, desto mehr wird abgeschaltet und desto länger dauert das Aufwachen. Was auf meiner Maschine ankommt, steht im sysfs:

POLL   0 us
C1     1 us
C1E    4 us
C6   170 us

C6 ist der interessante: dort wird der Kernzustand weggeschrieben und der Kern faktisch abgeschaltet. Das spart richtig Strom, kostet aber 170 Mikrosekunden zum Aufwachen. Für einen Rechner unter dem Schreibtisch ist das vollkommen egal. Für einen Paketfilter, der Latenz im einstelligen Mikrosekundenbereich garantieren soll, ist es das nicht, und genau deshalb steht in Tuning-Anleitungen für Netzwerkkram regelmässig, man solle die tiefen C-States abschalten.

Enable Monitor MWAIT ist die Voraussetzung für den ganzen Mechanismus: MONITOR und MWAIT sind die beiden Befehle, mit denen ein Kern sagt „weck mich, wenn sich diese Speicherstelle ändert“ und sich danach schlafen legt. Ohne sie bleibt nur die alte HLT-Schleife.

Package C State [Auto] eine Menüebene weiter ist die Steigerung davon: nicht mehr einzelne Kerne, sondern das ganze Paket samt Uncore und Speichercontroller. Das geht natürlich nur, wenn wirklich alle Kerne gleichzeitig nichts tun.

T-States, und warum sie aus sind

Software Controlled T-States      [Disable]

Der dritte Buchstabe im Bunde, und der unangenehmste. T-States drosseln nicht die Frequenz, sondern schieben Pausen ein: die CPU bekommt für einen Teil der Zeit schlicht keinen Takt. Das ist deutlich brutaler als ein P-State und eigentlich eine Notbremse für den Fall, dass es zu heiss wird.

Auf Disable heisst, dass das Betriebssystem diese Notbremse nicht selbst ziehen darf. Der thermische Schutz der Hardware bleibt davon unberührt, der arbeitet unabhängig weiter. Bei Tjmax 103 Grad und CPU-Temperaturen um die 40 im Leerlauf ist das eine sehr theoretische Diskussion.

Der Rest

Frequency Prioritization
RAPL Prioritization               [Disable]

RAPL ist Intels Mechanismus zur Leistungsbegrenzung. Mit RAPL Prioritization verteilt die Firmware ein knappes Leistungsbudget gezielt auf einzelne Kerne, statt alle gleichmässig zu drosseln. Das ergibt Sinn, wenn man dauerhaft am Powerlimit fährt und weiss, welche Kerne die wichtige Arbeit machen. Meine Maschine ist von beidem weit entfernt.

Was ich mitnehme

Der ganze Block ist erstaunlich gut eingestellt für ein Board, das im Auslieferungszustand von einem Serverhersteller kam. SpeedStep an, Turbo an, C-States an, T-States aus, das würde ich genau so einstellen. Ob da Supermicro oder Thomas Krenn sich Mühe gegeben hat?

Der einzige echte Fund ist Hardware P-States [Disable]. Und der ist vor allem deshalb schön, weil er zeigt, wozu so ein Durchgang gut ist: ich habe seit Wochen ein Verhalten beobachtet, das mich gewundert hat, und die Ursache lag die ganze Zeit sichtbar in einem Menü. Ich musste nur hineinschauen.

Nächste Folge: der Speicher. ECC, Patrol Scrub, PPR, und die Frage warum 256 GB DDR4-3200 in dieser Maschine mit 2933 laufen, klingt ja falsch oder?

Siehe auch

Falls jemand von euch intel_pstate im aktiven Modus gegen schedutil gemessen hat, auf einem Xeon und nicht auf einem Notebook: erzähl mir davon, bevor ich es selbst ausprobiere. Ihr dürft mich gerne fragen.

BIOS erklärt, Teil 4: AES-NI, SGX, TME und ein Schalter der nichts bewirkt

In der letzten Folge ging es um die obere Hälfte der CPU-Seite, also Kerne, Threads und Prefetcher. Die untere Hälfte ist die spannendere, weil dort keine Leistungsschalter mehr stehen, sondern Sicherheitsfunktionen. Und weil mindestens einer davon eine richtig gute Geschichte hat.

Zur Erinnerung für alle die neu dazustossen: ich gehe hier das BIOS meines Supermicro-Serverboards durch, Themenblock für Themenblock, alle Folgen liegen unter BIOS & Firmware.

AES-NI, oder: der Grund warum es eine Karte gab

Aptio Setup, CPU Configuration, mit Hyper-Threading, den fünf Prefetchern und AES-NI
AES-NI                          [Enable]

Ein Schalter und dahinter steckt eine ganze Ära. AES-NI sind sechs zusätzliche Prozessorbefehle, die AES in Hardware rechnen statt in Software. Der Unterschied ist keine Feinheit, er liegt grob bei einer Grössenordnung, und obendrein sind die Hardwarebefehle immun gegen die Cache-Timing-Seitenkanäle, mit denen man Software-AES angreifen kann. Aber vor allem is et dann ers rischtig flott mit der Crypto.

Ob die CPU es kann und darf, sieht man sofort im OS mit:

grep -o ' aes ' /proc/cpuinfo | head -1
#    aes

Warum es diesen Schalter überhaupt gibt, ist eine berechtigte Frage. Man schaltet AES-NI ja nicht ab, weil man gern langsam verschlüsselt. Die ehrliche Antwort ist vermutlich, dass er aus einer Zeit stammt, in der AES-NI ein Unterscheidungsmerkmal zwischen Produktlinien war, und niemand hat ihn seitdem entfernt. Oder hat jemand eine bessere Erklärung?

Über genau diese Zeit habe ich hier schon geschrieben. In meinem Beitrag zur Intel QuickAssist 8950-SCCP steckt eine Karte, die es nur deshalb gab, weil manche CPUs kein AES-NI hatten. Wer 2013 ein NAS mit einer Atom-CPU verschlüsseln wollte, brauchte Hilfe. Heute ist der Schalter eine Formalie, damals war er die Grenze zwischen „geht“ und „geht nicht“.

Virtualisierung und ein Schalter der Vertrauen heisst

Intel Virtualization Technology [Enable]
Enable SMX                      [Disable]

Der erste ist VT-x, im Handbuch etwas altmodisch VMX und in einer noch älteren Fassung sogar Vanderpool Technology genannt, nach dem Codenamen von 2003. Ohne ihn läuft keine Hardware-Virtualisierung, also weder KVM noch VirtualBox mit brauchbarer Geschwindigkeit. Bleibt selbstverständlich an.

Der zweite ist interessanter. SMX steht für Safer Mode Extensions und ist die Grundlage für Intel TXT, Trusted Execution Technology. Vereinfacht gesagt geht es darum, dass eine Maschine beim Start beweisen kann, dass sie genau die Software geladen hat, die sie laden sollte. Ein gemessener Start, verankert in der Hardware, ähnlich wie Measured Boot mit einem TPM, nur eine Ebene tiefer.

Dabei ist mir etwas aufgefallen, das ich nicht restlos erklären kann. Im Setup steht SMX auf Disable. In /proc/cpuinfo steht das Flag smx trotzdem drin. Die naheliegende Deutung ist, dass das Flag die Fähigkeit der CPU beschreibt und der BIOS-Schalter nur die Freigabe, dass CPUID also weiterhin „kann ich“ meldet, während die Firmware „darfst du nicht“ sagt. Belegen kann ich das nicht, ich habe es nur beobachtet. Wer da genauer Bescheid weiss, immer her damit. Ich bin ja auch nur doof.

Speicherverschlüsselung, und warum SGX daran hängt

Total Memory Encryption (TME)   [Disabled]

TME verschlüsselt den kompletten Arbeitsspeicher. Der Schlüssel wird bei jedem Start neu erzeugt, liegt im Speichercontroller und verlässt die CPU nie. Für das Betriebssystem ist das unsichtbar, es merkt schlicht nichts davon.

Wogegen hilft das? Gegen Angriffe, die physisch am Speicher ansetzen. Der Klassiker ist der Cold-Boot-Angriff, bei dem man die Riegel kühlt, aus der laufenden Maschine reisst und in einem anderen Gerät ausliest, weil DRAM seinen Inhalt für Sekunden bis Minuten behält. Dazu kommt alles, was per DMA am Speicher horcht. Bei einem Server im fremden Rechenzentrum ist das kein theoretisches Szenario. Sucht das mal auf YouTube ist so, das sieht immer krass aus. Vielleicht kann ich so etwas selbst irgendwann mal probieren.

Bei mir steht es auf Disabled, und das ist der Werksdefault. Meine Maschine steht in meiner Wohnung, die Systemplatte ist ohnehin mit LUKS verschlüsselt, und das Angriffsmodell, gegen das TME hilft, setzt jemanden voraus der hier steht während die Kiste läuft. Falls das passiert, habe ich grössere Probleme. Reizvoll finde ich es trotzdem, denn die Kosten sind gering: die Verschlüsselung sitzt im Speichercontroller und macht sich in Messungen kaum bemerkbar.

Ein Detail, über das ich beim Lesen des Handbuchs gestolpert bin, erklärt den Aufbau des Menüs:

*If the feature above is set to Enabled, the next five features are displayed*

Erst wenn TME an ist, erscheinen die fünf Zeilen darunter, und dazu gehört auch SGX. Die Enklaven-Technik hängt hier also an der Speicherverschlüsselung. Das ergibt Sinn, denn der geschützte Speicherbereich einer Enklave muss ja gegen genau die Zugriffe abgesichert sein, die TME abdeckt. Im Setup sieht man davon nur eine flache Liste, im Handbuch die Abhängigkeit.

SGX, und wie Intel die UHD-Blu-ray auf dem PC beerdigt hat

SW Guard Extensions (SGX)       [Disabled]
SGX Factory Reset               [Disabled]
SGX Package Info In-Band Access [Disabled]

SGX ist die Idee, dass ein Programm einen Speicherbereich anlegen kann, in den niemand hineinsehen darf. Nicht andere Programme, nicht der Kernel, nicht der Hypervisor, nicht einmal jemand mit Root. Die CPU verschlüsselt den Bereich und gibt ihn nur dem Code frei, der ihn angelegt hat. Diese Enklaven waren als Fundament für vertrauliche Berechnungen in fremden Rechenzentren gedacht.

Die Geschichte drumherum ist allerdings die bessere. SGX war jahrelang in normalen Desktop-Prozessoren drin, und die prominenteste Anwendung war nicht vertrauliches Rechnen, sondern Kopierschutz. Die Wiedergabe einer Ultra-HD-Blu-ray auf dem PC verlangte SGX, weil der Schlüsselaustausch in einer Enklave passierte. Dann hat Intel SGX ab der elften Generation aus den Consumer-CPUs entfernt, und damit war die UHD-Blu-ray-Wiedergabe auf neuen PCs vorbei. Wer eine Scheibe abspielen wollte, brauchte plötzlich einen älteren Prozessor.

Ich finde das aus mehreren Gründen bemerkenswert. Erstens ist es ein schönes Beispiel dafür, wie eine Sicherheitstechnik zweckentfremdet wird und dann an einer Produktentscheidung stirbt, die mit ihrem eigentlichen Zweck nichts zu tun hat. Zweitens hat es Leute getroffen, die für ihre legal gekauften Scheiben plötzlich keinen legalen Abspielweg mehr hatten, während der inoffizielle Weg weiterhin problemlos funktionierte. Kopierschutz eben.

Im Xeon lebt SGX weiter, dort war es nie der Kopierschutz sondern das Verkaufsargument. Bei mir steht es trotzdem aus, weil ich keine Software habe die Enklaven nutzt, und weil es ohnehin nur zusammen mit TME ginge. Der Vollständigkeit halber, im laufenden System taucht folgerichtig nichts davon auf:

grep -c ' sgx' /proc/cpuinfo    # 0
ls /sys/devices/system/cpu/ | grep -c sgx    # 0

46 Bit, wegen Hyper-V

Limit CPU PA to 46 bits         [Enable]

PA steht für Physical Address. Der Schalter begrenzt die Breite der physischen Adressen auf 46 Bit, was 64 Terabyte adressierbarem Speicher entspricht. Bei 256 GB im Rechner ist das reichlich weit weg von jeder Grenze. 46 Bit und 64 Bit, da muss ich direkt an fe80 bei IPv6 denken und das man die Luft gelassen hat, weil MAC Adressen auch irgendwann zu wenige sind. Ok da sind es 48 Bit aber denken muss ich daran.

Die Begründung im Handbuch ist knapp und ziemlich entlarvend:

Use this feature to limit the CPU physical address to 46 bits to support older hyper-v.

Ältere Hyper-V-Versionen kamen mit breiteren Adressen nicht klar, also gibt es im BIOS eines Serverboards von 2021 einen Schalter, der die CPU künstlich einschränkt, damit eine bestimmte Microsoft-Software läuft. Das ist Abwärtskompatibilität in ihrer reinsten Form. Der Effekt lässt sich direkt nachlesen:

grep -m1 'address sizes' /proc/cpuinfo
#   address sizes : 46 bits physical, 57 bits virtual

46 physisch, wie eingestellt. Die 57 virtuellen Bit sind übrigens eine ganz andere Baustelle, das ist Five-Level Paging, ebenfalls neu mit Ice Lake.

Eine Seriennummer im Prozessor

Aptio Setup, untere Hälfte der CPU Configuration, mit TME, SGX, PPIN Control und Extended APIC
PPIN Control                    [Lock/Disable]

PPIN steht für Protected Processor Inventory Number, eine eindeutige Nummer pro physischer CPU. Gedacht ist sie für die Fehleranalyse in grossen Flotten: wenn ein Prozessor Speicherfehler meldet, will man wissen welcher es war, und zwar auch nachdem er längst ausgebaut wurde.

Eine eindeutige, unveränderliche Hardwarenummer ist allerdings genau die Sorte Datenpunkt, an der sich vor Jahren schon einmal die Gemüter erhitzt haben. Der Pentium III hatte eine Seriennummer, der Aufschrei war so gross, dass Intel sie wieder entfernt hat. PPIN ist die gleiche Idee, nur diesmal mit einem Schalter davor und beschränkt auf Serverprozessoren.

Hier ist mir eine Abweichung aufgefallen. Das Handbuch nennt als mögliche Werte:

The options are Unlock/Disable and Unlock/Enable.

Im Setup steht aber Lock/Disable, ein Wert den das Handbuch gar nicht kennt. Passend dazu fehlt im laufenden System das Flag:

grep -c intel_ppin /proc/cpuinfo    # 0

Meine Lesart: Lock bedeutet, dass das Register gesperrt ist und die Nummer nicht ausgelesen werden kann, und das Handbuch ist an dieser Stelle schlicht nicht auf dem Stand der Firmware. Für mich ist das der Wunschzustand, ich brauche keine Seriennummer und niemand sonst braucht sie von mir. Nur schön dokumentiert ist es eben nicht.

Und zum Schluss der Schalter, der nichts bewirkt

Extended APIC                   [Disable]

Das ist mein Lieblingsfund auf dieser Seite, weil er aussieht wie eine vergessene Optimierung. Der APIC ist der Interrupt-Controller. x2APIC ist seine erweiterte Fassung, die mehr adressierbare Prozessoren und schnelleren Zugriff über Register statt über den Speicher erlaubt. Auf Disable denkt man reflexhaft: aha, hier liegt Leistung brach, das schalte ich mal ein.

Nur ist die Funktion längst aktiv. Aus dem Kernel-Log meiner laufenden Maschine:

DMAR-IR: Queued invalidation will be enabled to support x2apic and Intr-remapping.
DMAR-IR: Enabled IRQ remapping in x2apic mode
x2apic enabled
APIC: Switched APIC routing to: cluster x2apic

Der Grund ist eine Abhängigkeit, die man kennen muss: Interrupt-Remapping über VT-d verlangt x2APIC. Sobald der Kernel die IOMMU mit Interrupt-Remapping hochfährt, schaltet er x2APIC selbst ein, unabhängig davon was die Firmware vorher getan hat. Der BIOS-Schalter steuert nur, ob die Firmware das schon vorab macht. B.T.W.: habe ihr euch MMU beim Commodore C128 mal angeschaut?

Ich lasse ihn deshalb bewusst in Ruhe, und ich schreibe das hier so ausführlich auf, weil er genau die Art Schalter ist, an dem man beim nächsten Setup-Besuch wieder hängenbleibt und denkt, man hätte etwas übersehen. Hat man nicht.

Nächste Folge: Speicher. ECC, Patrol Scrub, und die Frage warum mein DDR4-3200 mit 2933 läuft und das völlig in Ordnung ist.

Siehe auch

Weiss jemand genauer, warum das smx-Flag in /proc/cpuinfo auftaucht obwohl der BIOS-Schalter auf Disable steht? Ich habe dazu keine belastbare Quelle gefunden und würde es gern richtig verstehen. Ihr dürft mich jederzeit fragen.

Kein SMART für SD-Karten: sieben Linux-Werkzeuge, die trotzdem verraten, wie gesund eine Karte ist

Transcend-8-GB-microSD-Karte im SD-Adapter und USB-Kartenleser neben Linux-Werkzeugen zur Prüfung von Kapazität, Datenintegrität und Schreibleistung ohne SMART.

Eine alte 8-GB-microSD-Karte lag noch in der Schublade, entbehrlich genug für einen Härtetest. Die Ausgangsfrage war simpel: ist die Karte noch gut, oder produziert sie im Hintergrund längst stille Fehler? Bei einer SSD würde ich smartctl -a tippen und hätte binnen Sekunden Health-Prozent, Reallocated Sectors und Power-On-Hours auf dem Schirm. Bei einer SD-Karte geht genau das nicht, und der Grund dafür ist interessanter als die fehlende Zahl selbst.

Warum SMART hier nicht funktioniert

ATA- und NVMe-Laufwerke haben eine standardisierte SMART-Schnittstelle: fest definierte Attribute, die die Firmware selbst pflegt und die jedes Betriebssystem auf die gleiche Weise abfragen kann. SD-Karten haben nichts Vergleichbares. Der Flash-Controller auf der Karte übernimmt zwar Wear-Leveling und Bad-Block-Mapping, aber wie er das im Detail tut und was er darüber nach außen preisgibt, ist herstellerspezifisch und komplett verschlossen. Es gibt zwar eine SD Health Status Extension, CMD56-basiert, die unterstützt aber so gut wie kein Consumer-Werkzeug, und sie setzt einen nativen MMC-Host voraus, keinen USB-Kartenleser.

Ein Wort noch zu TRIM, bevor der Eindruck entsteht, SD-Karten hätten damit überhaupt nichts zu tun: der Linux-MMC-Stack unterstützt discard-artige Operationen für native mmcblk-Geräte durchaus. Nur bringt das über einen USB-Kartenleser nichts, weil die USB-Massenspeicher-Übersetzung diesen Pfad gar nicht durchreicht, das habe ich in diesem Test empirisch bestätigt. Und selbst auf einem nativen Host wäre das nur ein Erase-Hinweis an den Controller, keine standardisierte Health-Rückmeldung wie bei SSD-TRIM zusammen mit SMART.

Testkandidat und Aufbau

Für den Test kam eine Transcend Premium 8GB microSDHC zum Einsatz, Class 10, UHS-Speed-Class U1, in einem Samsung-Adapter auf volle SD-Größe gesteckt und über einen UGREEN-USB-Kartenleser ausgelesen. Der Leser meldet sich per USB als Genesys-Logic-Chip (05e3:0748), ein generischer Combo-Reader, wie er auch in vielen Laptops und günstigen USB-Dongles verbaut ist. Host war ein Linux Mint 22.3 mit Kernel 7.0.0-28-generic, Ubuntu-24.04-Unterbau, alle Befehle liefen als root.

MicroSD-Karte im Samsung-Adapter, gesteckt in einen schwarzen USB-Kartenleser auf einer Küchenarbeitsplatte
So kam die Karte beim Testsystem an: microSD im Samsung-Adapter, gesteckt in den UGREEN-USB-Kartenleser.

Werkzeuge installieren

Bis auf badblocks, smartctl, hdparm und dd, die auf den meisten Systemen ohnehin vorhanden sind, kommt der Rest aus den Standard-Paketquellen, keine Drittanbieter-PPA nötig:

apt-get install -y f3 mmc-utils sdparm flashbench
apt-get install -y fio   # nachinstalliert, sobald der Schritt feststand

Installiert wurden f3 8.0, mmc-utils als Git-Snapshot von 2022, sdparm 1.12, flashbench 62 und fio 3.36, alles aus dem Ubuntu-Noble-Universe-Repository. Getestet auf Linux Mint 22.3, aber auf einem reinen Ubuntu 24.04 sollten Paketnamen und Versionen identisch sein.

Schritt 1: Identifikation, warum SMART eine Sackgasse ist

$ smartctl -a /dev/sdc
/dev/sdc: Unknown USB bridge [0x05e3:0x0748 (0x1209)]
Please specify device type with the -d option.

$ smartctl -a -d sat /dev/sdc
Read Device Identity failed: scsi error unsupported scsi opcode
A mandatory SMART command failed: exiting.

$ smartctl --scan
/dev/nvme0 -d nvme
/dev/nvme1 -d nvme
# sdc taucht gar nicht erst auf, smartd hält es für keinen unterstützten Gerätetyp

$ sdparm -a /dev/sdc
MODE SENSE(10): Malformed SCSI command
    /dev/sdc: Generic   MassStorageClass  1209

$ sdparm -i /dev/sdc
    /dev/sdc: Generic   MassStorageClass  1209
Device identification VPD page:
  Addressed logical unit:
    designator type: T10 vendor identification,  code set: ASCII
      vendor id: Generic
      vendor specific: STORAGE DEVICE

smartctl erkennt die Genesys-Logic-USB-Bridge gar nicht erst als unterstützten Gerätetyp. Erzwingt man SAT, also ATA-Kommandos über SCSI getunnelt, scheitert das vollständig, weil die Bridge-Firmware das Kommando schlicht nicht implementiert. Selbst der Geräte-Scan von smartd listet /dev/sdc gar nicht auf. sdparm kommt einen Schritt weiter, eine simple SCSI-INQUIRY funktioniert, aber die gemeldete Identität bleibt vollständig generisch: „Generic MassStorageClass“, „STORAGE DEVICE“. Weder Hersteller-ID noch Seriennummer noch irgendeine Form von Wear- oder Health-Daten sind über diesen Pfad erreichbar. Das ist der konkrete, reproduzierbare Beleg dafür, dass es für SD-Karten hinter einem USB-Leser kein SMART-Äquivalent gibt.

$ lsusb -v -d 05e3:0748
Bus 002 Device 007: ID 05e3:0748 Genesys Logic, Inc. All-in-One Cardreader
  bcdUSB               3.10
  idVendor           0x05e3 Genesys Logic, Inc.
  idProduct          0x0748 All-in-One Cardreader
  iSerial                 5 000000001209
  bInterfaceClass         8 Mass Storage
  bInterfaceSubClass      6 SCSI
  bInterfaceProtocol     80 Bulk-Only
  SuperSpeed USB Device Capability:
    wSpeedsSupported   0x000e  (Full/High/SuperSpeed, bis zu 5Gbps Link)

Der Leser meldet sich als „All-in-One Cardreader“ von Genesys Logic, ein verbreiteter generischer Combo-Chip, USB-3.1-fähig, spricht auf der SCSI-Ebene aber Bulk-Only Transport (BOT), nicht UAS. Wichtig für die Kausalität: BOT statt UAS ist für sich genommen nicht der Grund, warum SAT-Passthrough scheitert. Es gibt genug BOT-Bridges, die SAT unterstützen, und genug, die es nicht tun, unabhängig vom Transportprotokoll. Belegt ist hier nur, dass ausgerechnet diese Genesys-Bridge keine ATA- beziehungsweise SAT-Kommandos durchreicht, nicht dass USB-Bulk-Only-Bridges das grundsätzlich nicht könnten.

Transcend Premium 8GB microSDHC Class 10 U1 im Samsung-Adapter, liegend auf einem UGREEN-USB-Kartenleser mit blau leuchtender Status-LED
Der Aufdruck verrät mehr als jedes Software-Tool: Transcend Premium, 8GB, Class 10, UHS-Speed-Class U1.

Schritt 2: Durchsatz mit dd und hdparm

hdparm -t allein ergab rund 90 MB/s gepuffertes Lesen, plausibel, aber es lohnt sich, mit echtem O_DIRECT-I/O gegenzuprüfen, damit keine Bridge- oder Seiten-Cache-Effekte den Wert verfälschen.

$ hdparm -Tt /dev/sdc
 Timing cached reads:   24182 MB in  2.00 seconds = 12113.48 MB/sec
 Timing buffered disk reads: 272 MB in  3.00 seconds =  90.66 MB/sec

# 512MB Zufallsnutzlast zuerst im tmpfs erzeugt, damit die CPU-Last von
# /dev/urandom die eigentliche Messung nicht verfälscht
$ dd if=/dev/urandom of=/root/sdtest/payload.bin bs=1M count=512
536870912 Bytes (537 MB, 512 MiB) kopiert, 1,74754 s, 307 MB/s

# SCHREIBEN: Direct I/O direkt auf das Rohgerät, kein Seiten-Cache beteiligt
$ dd if=/root/sdtest/payload.bin of=/dev/sdc bs=1M count=512 oflag=direct,sync
536870912 Bytes (537 MB, 512 MiB) kopiert, 22,8654 s, 23,5 MB/s

# LESEN: erst flushen, dann Direct I/O der gleichen 512MB zurück
$ blockdev --flushbufs /dev/sdc
$ dd if=/dev/sdc of=/root/sdtest/readback.bin bs=1M count=512 iflag=direct
536870912 Bytes (537 MB, 512 MiB) kopiert, 6,28156 s, 85,5 MB/s

# Integritätscheck
$ cmp /root/sdtest/payload.bin /root/sdtest/readback.bin
MATCH: identical

Ergebnis: rund 23,5 MB/s sequenzielles Schreiben, rund 85,5 MB/s sequenzielles Lesen, mit byteidentischem Rücklese-Ergebnis. Die Asymmetrie zwischen Schreiben und Lesen ist normal und erwartbar, Schreiben braucht auf Flash-Ebene ein Erase-before-Program, Lesen nicht. 23,5 MB/s reißt die Class-10- und U1-Mindestangabe von 10 MB/s locker, für U3/V30 mit 30 MB/s Minimum würde es nicht reichen, was exakt zum aufgedruckten Rating der Karte passt: Class 10, U1, keine U3- oder V30-Kennzeichnung.

Schritt 3: f3probe, ist die Kapazität echt?

Bevor der große, langsame Test kommt, lohnt sich der schnelle: f3 (Fight Flash Fraud) bringt mit f3probe einen Kapazitätsbetrugs-Check, der binnen weniger Minuten läuft, statt die ganze Karte zu beschreiben.

$ f3probe --time-ops /dev/sdc

Probe finished, recovering blocks... Done

Good news: The device `/dev/sdc' is the real thing

Device geometry:
	         *Usable* size: 7.31 GB (15333376 blocks)
	        Announced size: 7.31 GB (15333376 blocks)
	                Module: 8.00 GB (2^33 Bytes)
	Approximate cache size: 0.00 Byte (0 blocks), need-reset=no
	   Physical block size: 512.00 Byte (2^9 Bytes)

Probe time: 1'46"
 Operation: total time / count = avg time
      Read: 29.32s / 4197116 = 6us
     Write: 1'15" / 4192321 = 18us
     Reset: 0us / 1 = 0us

Verdikt: eine echte Karte, keine Fälschung. Die angekündigte Größe, 8-GB-Modul mit 7,31 GB nutzbar, deckt sich mit der tatsächlich nutzbaren Größe, die f3probe per binärer Suche gefunden hat, also dort, wo Schreibvorgänge aufhören, korrekt anzukommen. Approximate cache size: 0.00 Byte ist ebenfalls ein gutes Zeichen: manche gefälschten Karten täuschen ihre Kapazität vor, indem sie eine kleine Menge echten Flashs als Write-Back-Cache benutzen, der Schreibvorgänge über die tatsächliche Kapazität hinaus vorübergehend „schluckt“ und damit naive Benchmarks täuscht. Diese Karte zeigt diesen Trick nicht. f3probe hat die für die Prüfung benutzten Blöcke danach wiederhergestellt, ohne -n gestartet, die Karte blieb also in ihrem vorherigen, leeren Zustand. Reine Probe-Zeit rund 1:46 Minuten, mit Block-Wiederherstellung insgesamt rund 3 Minuten, deutlich schneller als ein vollständiger Schreib-Lese-Durchlauf, weil f3probe gezielt sucht statt jeden Block anzufassen.

Schritt 4: f3write und f3read, der Haupttest

$ wipefs -a /dev/sdc
$ parted -s /dev/sdc mklabel gpt mkpart primary ext4 0% 100%
$ mkfs.ext4 -F -L SDTEST /dev/sdc1
$ mount /dev/sdc1 /mnt/sdtest
$ df -h /mnt/sdtest
/dev/sdc1       7,2G     24K  6,8G    1% /mnt/sdtest

$ cd /mnt/sdtest && f3write .
Free space: 7.10 GB
Creating file 1.h2w ... OK!
...
Creating file 8.h2w ... OK!
Free space: 16.46 MB
Average writing speed: 22.78 MB/s

$ f3read .
                  SECTORS      ok/corrupted/changed/overwritten
Validating file 1.h2w ... 2097152/        0/      0/      0
...
Validating file 8.h2w ...  176128/        0/      0/      0

  Data OK: 7.08 GB (14856192 sectors)
Data LOST: 0.00 Byte (0 sectors)
	       Corrupted: 0.00 Byte (0 sectors)
	Slightly changed: 0.00 Byte (0 sectors)
	     Overwritten: 0.00 Byte (0 sectors)
Average reading speed: 90.32 MB/s

Verdikt: sauber. Jedes Byte jeder der 8 Testdateien, 7,08 GB insgesamt, bis auf rund 16 MB Restplatz gefüllt, kam exakt so zurück, wie es geschrieben wurde: 0 korrupte, 0 veränderte, 0 überschriebene Sektoren. Die gemessenen Geschwindigkeiten, 22,78 MB/s Schreiben und 90,32 MB/s Lesen, decken sich eng mit dem rohen dd-Direct-I/O-Benchmark aus Schritt 2, eine gute Gegenprobe, dass die Zahlen echt sind und kein Artefakt einer einzelnen Methode.

Schritt 5: badblocks, die Musterprüfung

$ umount /mnt/sdtest
$ badblocks -wsv /dev/sdc

Es wird nach defekten Blöcken gesucht (Lesen+Schreiben-Modus)
Von Block 0 bis 7666687
Es wird getestet Mit Muster 0xaa: ... 100% erledigt
Es wird getestet Mit Muster 0x55: ... 100% erledigt
Es wird getestet Mit Muster 0xff: ... 100% erledigt
Es wird getestet Mit Muster 0x00: ... 100% erledigt
Durchgang beendet, 0 defekte Blöcke gefunden. (0/0/0 Fehler)

Verdikt: 0 defekte Blöcke, über alle 4 Standard-Testmuster hinweg (0xAA/01010101, 0x55/10101010, 0xFF/lauter Einsen, 0x00/lauter Nullen, gewählt, um Stuck-at-0- und Stuck-at-1-Zellfehler ebenso wie Kopplungsfehler zwischen Nachbarbits zu erwischen). Gesamtlaufzeit für den kompletten Schreib-Lese-Vergleichs-Zyklus über alle 4 Muster: rund 26 Minuten 47 Sekunden auf dieser 7,31-GiB-Karte, was zur Schätzung von rund 27 Minuten aus den früheren Durchsatzwerten passt, 4-mal Schreibdurchlauf plus Lesedurchlauf bei rund 23,5/85 MB/s.

badblocks -w ist ein linearer, positionsbasierter Test, Teil von e2fsprogs. In einem Durchgang schreibt er dasselbe Muster auf jeden logischen Block und liest es zurück. Genau das ist der strukturelle Unterschied zu f3write/f3read, und der Grund, warum badblocks kein Ersatz für f3 bei der Kapazitätsbetrugsfrage ist: eine Karte, die Schreibvorgänge still auf einen kleineren physischen Bereich zurückführt, könnte in einem badblocks-Durchgang trotzdem das „richtige“ Muster zurückliefern, weil ohnehin jeder logische Block identischen Inhalt bekommt, das Aliasing bliebe unsichtbar. f3write vermeidet das, indem es positionsabhängige Pseudozufallsdaten schreibt, sodass Wraparound oder Aliasing als Mismatch auffällt. badblocks beantwortet dafür eine engere, ergänzende Frage: versagen bestimmte Blöcke oder Zellen dabei, irgendein Muster zuverlässig zu speichern, was der einmalige, pseudozufällige Schreibdurchgang von f3 pro Karte nicht so systematisch prüft, kein 0xAA/0x55/0xFF/0x00-Stuck-Bit-Sweep.

Schritt 6: flashbench, ein Blick unter die Haube

$ flashbench -f -o /root/sdtest/flashbench.out /dev/sdc
$ cat /root/sdtest/flashbench.out

4MiB    28.5M/s  28.3M/s  26.7M/s  27.6M/s  23.4M/s  28M/s
2MiB    26.6M/s  28.2M/s  26.7M/s  28.1M/s  26.8M/s  28.1M/s
1MiB    26.6M/s  28.8M/s  26.4M/s  28.4M/s  26.6M/s  27.3M/s
512KiB  26.7M/s  28.2M/s  26.9M/s  28.9M/s  26.8M/s  28.3M/s
256KiB  26.7M/s  28.2M/s  26.6M/s  27.8M/s  27M/s    28.8M/s
128KiB  26M/s    28.5M/s  26.7M/s  28.3M/s  26.8M/s  28.3M/s
64KiB   26.9M/s  28.8M/s  26.8M/s  28.3M/s  26.7M/s  27.9M/s
32KiB   12.6M/s  12.7M/s  13.1M/s  12.6M/s  12.9M/s  12.8M/s
16KiB   5.78M/s  5.91M/s  5.87M/s  5.82M/s  5.82M/s  5.86M/s

flashbench -f (find-fat) liest am Ende der ersten paar Erase-Blöcke zunehmend kleinere Häppchen und misst die Zeit dafür, auf der Suche nach dem Punkt, an dem die Lesegeschwindigkeit plötzlich einbricht. Die Grundidee: das deutet auf die interne Lese- beziehungsweise Page-Granularität des Flashs hin, weil das Lesen eines Teils einer internen Page oder eines Blocks genauso viel kosten kann wie das Lesen der ganzen Einheit, sub-granulare Reads verschwenden dann Bandbreite. Hier hält sich das Plateau, rund 26 bis 29 MB/s, passend zum rohen sequenziellen Lese-Benchmark, stabil bis hinunter zu 64 KiB, bricht dann bei 32 KiB auf weniger als die Hälfte ein (rund 12,6 bis 13,1 MB/s) und nochmal auf etwa ein Fünftel bei 16 KiB (rund 5,8 bis 5,9 MB/s). Ein sauberes, reproduzierbares Signal, 6 Wiederholungen pro Zeile, alle konsistent.

Die Schlussfolgerung bleibt bewusst vorsichtig formuliert: das ist ein Performance-Knick bei 64 KiB auf diesem konkreten Reader-Controller-Pfad, konsistent mit einer größeren internen Leseeinheit oder FTL-Gruppierung, aber keine direkte, verifizierte Messung der tatsächlichen physischen NAND-Page-Größe der Karte (typischerweise 8 bis 16 KiB, dafür bräuchte es Herstellerdokumentation oder eine andere Messmethode). Die Daten sind mit einer bestimmten Erklärung konsistent, sie beweisen sie nicht.

Schritt 7: fio, bleibt die Schreibrate konstant?

dd und f3write liefern Durchschnittswerte. fio mit einem Bandbreiten-Log pro Sekunde zeigt dagegen die Form des Schreibvorgangs über die vollen 6 GB, genau das will man sehen, wenn man einen Pseudo-SLC-Cache-Absturz sucht: viele Karten schreiben schnell in einen kleinen SLC-Modus-Puffer, bis der voll ist, und fallen danach auf eine deutlich niedrigere TLC- oder QLC-Dauerschreibrate ab.

$ fio --name=sdcard-write --filename=/dev/sdc --rw=write --bs=1M --size=6G --direct=1 --ioengine=psync --iodepth=1 --write_bw_log=/root/sdtest/fio_write --log_avg_msec=1000 --group_reporting

write: IOPS=26, BW=26.0MiB/s (27.3MB/s)(6144MiB/236179msec)
bw (KiB/s): min=24576, max=27675, per=100.00%, avg=26645.27, stdev=457.38, samples=236

Der Blick ins rohe Sekunden-Log lohnt sich, nicht nur die Zusammenfassung: 236 Einsekunden-Stichproben über den kompletten 6-GB-Schreibvorgang, alle im Bereich von 24576 bis 27675 KiB/s, also rund 24,0 bis 27,0 MB/s. Erste und letzte Stichprobe liegen im selben schmalen Band, kein erhöhter Burst am Anfang, kein Absturz später.

Verdikt: flach, kein erkennbarer SLC-Cache-Absturz auf dieser Karte. Auch hier lohnt sich die vorsichtige Formulierung: das beweist nicht, dass die Karte null Schreibpufferung hat, nur dass ein eventueller Puffer beziehungsweise Absturz innerhalb dieses 6-GB-Testfensters auf einer Karte mit nur rund 7,3 GB nutzbarer Gesamtkapazität nicht sichtbar wurde. Es könnte schlicht nicht genug Karte nach dem Puffer übrig sein, um einen Absturz zu zeigen, oder, wahrscheinlicher bei einer reinen Class-10/U1-Budgetkarte ohne A1/A2- oder „Extreme/Pro“-Einstufung, sie implementiert gar kein dynamisches SLC-Caching. So oder so steht das praktische Ergebnis: die Schreibleistung war über die gesamte Kapazität konsistent und vorhersagbar, nicht nach vorne verlagert.

Was für eMMC gilt, aber nicht für SD

Ein paar Dinge, die in diesem Testlauf nicht zum Einsatz kamen, aber der Vollständigkeit halber dazugehören. mmc extcsd read und seine Lebensdauer-Schätzfelder, das Nächste an einer echten Health-Prozentangabe in diesem Umfeld, sind eine eMMC-Eigenschaft. Eine wechselbare SD- oder microSD-Karte ist kein eMMC, dieser Pfad stand hier ohnehin nicht zur Verfügung, USB-Leser statt nativer MMC-Host. Ein nativer MMC-Host-Slot, etwa der eingebaute SD-Slot eines Laptops, würde dagegen mehr Identitätsdaten offenlegen als ein USB-Leser: /sys/block/mmcblkX/device/ mit cid, csd, scr, serial, manfid, name, oemid, preferred_erase_size und date, der Kernel parst die Identitätsregister der Karte direkt in sysfs, ganz ohne Zusatzwerkzeug. Wer also einen echten SD/MMC-Controller statt einer USB-SCSI-Bridge als Leser hat, bekommt das gratis dazu, in diesem Test war davon nichts erreichbar.

Und noch ein Missverständnis vorweg: der „SD Card Formatter“ der SD Association ist ein Formatierungs- und Reset-Werkzeug, das die werkseitige Partitionierung wiederherstellt, nachdem andere Betriebssysteme sie durcheinandergebracht haben, kein Health-Check-Werkzeug.

Die Reihenfolge, wenn du das nachmachen willst

Vom schnellsten und harmlosesten zum langsamsten und gründlichsten, ungefähr so, wie dieser Test auch abgelaufen ist:

  1. lsusb -v, udevadm info, dmesg. Leser und Karte identifizieren, Schreibschutz-Status vorab prüfen.
  2. smartctl -a -d sat und sdparm -a. Schneller, harmloser Versuch an Health- und Identitätsdaten, meist scheitert das über einen USB-Leser, und genau dieses Scheitern ist der Punkt, den man erklären sollte.
  3. f3probe. Schneller Kapazitätsbetrugs-Check, für 8 GB etwa 2 bis 3 Minuten, minimal destruktiv, stellt die benutzten Blöcke standardmäßig wieder her.
  4. f3write plus f3read. Der Haupttest, vollständige Schreib-Lese-Integritätsprüfung über die gesamte Kapazität, braucht ein Dateisystem.
  5. badblocks -w. Destruktive Vier-Muster-Prüfung auf defekte Blöcke, Rohgerät, kein Dateisystem nötig. Fängt Dinge, die f3 strukturell nicht kann, siehe Schritt 5.
  6. dd mit oflag/iflag=direct und/oder fio mit Bandbreiten-Log. Durchsatzzahlen und, mit fio, die Form der Schreibrate über die Zeit.
  7. flashbench -f -o DATEI. Optional, für die Neugier, Hinweise auf die interne Lese-Granularität.

Fazit

Für diese konkrete Karte: gesund, echt, keine Defekte gefunden, bei jedem Test, der überhaupt in der Lage gewesen wäre, welche zu finden. Keine SMART-Werte erreichbar über den USB-Leser, echte Kapazität ohne Cache-Trickserei, null Datenkorruption über 7,08 GB, null defekte Blöcke über alle 4 Muster, rund 23 bis 27 MB/s Schreiben und rund 85 bis 90 MB/s Lesen, konsistent über vier unabhängige Messmethoden, kein SLC-Cache-Absturz über die volle Kapazität. Die Karte reißt ihre aufgedruckte Class-10/U1-Angabe locker, für U3/V30 würde es nicht reichen, was sie auch gar nicht behauptet.

Der eigentliche Punkt reicht über diese eine Karte hinaus: ohne SMART bleibt nur Verhaltenstestung statt Register-Abfrage. Schreiben, lesen, vergleichen, und der Karte dabei zusehen, statt sie nach einer Zahl zu fragen, die sie gar nicht hat.

Siehe auch

Falls jemand einen zuverlässigeren Weg zur Health-Einschätzung einer SD-Karte unter Linux kennt, immer her damit. Und falls ihr selbst öfter mit fragwürdigen Billigkarten zu tun habt, dürft ihr mich zu dem Thema sehr gerne fragen.

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.

« Ältere Beiträge

© 2026 -=Kernel-Error=- — RSS

Theme von Anders Norén — Hoch ↑