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

Autor: Sebastian van de Meer (Seite 1 von 47)

Bosch Wärmepumpentrockner: Kondensator reinigen trotz SelfCleaning, Service-Klappe selbst geschnitten und gedruckt

Vor knapp 11 Jahren ist bei uns einer dieser Wärmepumpentrockner eingezogen. Das Gerät hat seitdem wirklich viel Wäsche gesehen, einen Haushalt mit dem täglichen Berg an Handtüchern, Bettzeug und Kinderkram trocknet so ein Ding nicht nebenbei. Über die ganze Zeit lief er eigentlich problemlos. Zwei Mal musste ich ihn zerlegen und reinigen, sonst nichts. Genau dieser Punkt ist es aber, der mich diesmal über eine Stunde und ein Teppichmesser gekostet hat.

Bosch Home Professional Wärmepumpentrockner WTY87701 von vorne mit Aufdruck SelfCleaning Condenser und ActiveAir Technology
Der Patient: Bosch WTY87701 Home Professional, ausgerechnet mit dem Aufdruck SelfCleaning Condenser.

Das Symptom ist immer das gleiche: Die Wäsche wird einfach nicht mehr richtig trocken. Teilweise musste ein Programm zwei bis drei Mal durchlaufen, bis das Zeug aus der Trommel wirklich trocken war. Für einen Trockner, der laut Datenblatt 232 kWh im Jahr ziehen soll, sind drei Durchläufe pro Ladung natürlich eine Katastrophe, energetisch wie zeitlich. Meine Frau hatte das Gerät zu dem Zeitpunkt schon angezählt. Es blieb also noch ein Versuch, dann fliegt es raus.

Warum ein Wärmepumpentrockner überhaupt zusetzt

Ein Wärmepumpentrockner ist im Kern ein geschlossener Kreislauf. Die warme, feuchte Luft aus der Trommel wird an einem Verdampfer abgekühlt, das Wasser kondensiert und landet im Tank, dann wird die Luft an einem Kondensator wieder aufgeheizt und zurück in die Trommel geblasen. Dieser Wärmetauscher mit seinen eng stehenden Aluminiumlamellen ist das Herz des Geräts. Und genau dort lagern sich über die Jahre feinste Flusen ab, die das Sieb passieren. Setzt sich die Lamellenfläche zu, kommt kaum noch Luft durch, der Wärmeaustausch bricht ein, und die Wäsche bleibt klamm. Genau das beschreiben auch die einschlägigen Reparaturanleitungen: Flusen in den hinteren Kondensatorlamellen führen zu langen Laufzeiten und einer Wärmepumpe, die sich abmüht.

Die Ironie: ein SelfCleaning Condenser, der trotzdem dicht ist

Das Schöne an meinem Gerät: vorne prangt in großen Lettern SelfCleaning Condenser. Bosch wirbt damit, dass eine manuelle Reinigung des Kondensators nicht mehr nötig sei. Technisch steckt dahinter, dass während eines Trockengangs mehrfach Kondenswasser aus dem Prozess abgezweigt und über die Kondensatorfläche gespült wird, um Flusen wegzuschwemmen. Das funktioniert auch, aber eben nur an der vorderen, gut erreichbaren Fläche, die der Spülstrahl trifft. Die tieferen Lamellen und der Verdampfer dahinter bekommen davon nichts ab. Dort sammelt sich der Feinstaub über ein Jahrzehnt trotzdem an. „Selbstreinigend“ heißt hier also „die vordere Ebene bleibt frei“, nicht „der ganze Wärmetauscher bleibt sauber“. Nach knapp 11 Jahren ist der Unterschied dramatisch.

Hier die Eckdaten zum Gerät und zum Bauteil, um das sich gleich alles dreht:

GerätBosch WTY87701, Home Professional
Typ / FDWDT66, FD 9505
BauartWärmepumpentrockner, 8 kg, A++, ActiveAir Technology, SelfCleaning Condenser
Service-Klappe (Teilenummer)BSH 00646776, ca. 225 x 145 mm, mit Dichtung und 6 Schrauben
Klappe passt laut Teilekatalog aufBosch Serie 6 / Serie 8 / Maxx 7, Siemens iQ300 bis iQ800
Typenschild des Bosch WTY87701 mit E-Nr WTY87701, Typ WDT66, FD-Nummer 9505 und Herstellungsangaben
Typenschild: E-Nr. WTY87701, Typ WDT66, FD 9505, mit allen Kenndaten für die Ersatzteilsuche.

Wo man rankommt, und wo eben nicht

Das große Flusensieb innen vor der Trommel ist schnell raus, da bekommt man mit schmalen Fingern auch etwas heraus, aber an die spannenden Stellen kommt man so nicht. Vorne am Gerät gibt es noch eine kleine Abdeckung. Rechts dahinter sitzt ein Lüfter, links sieht es zumindest so aus, als hätte dort mal jemand über eine Art Klappe nachgedacht. Und zwar genau an der Stelle, an der man eine Klappe bräuchte, wenn man den Kondensator selbst reinigen will. Bei meinen letzten beiden Reinigungen konnte ich alles andere öffnen und sauber machen, aber an den Kondensator selbst kam ich nie richtig heran. Diesmal sollte es anders laufen.

Die Klappe, die Bosch vergessen hat einzubauen

Jetzt kommt der Teil, der mich erst gewundert und dann geärgert hat. Die Stelle, an der ich die Klappe erwartet hätte, ist im Gehäuse fast schon vorperforiert. Die Kontur ist angedeutet, das Material an der Linie spürbar dünner. Mit einem Teppichmesser lässt sich die Öffnung sehr einfach herstellen, man folgt einfach der angedachten Linie. Heißt im Klartext: Bosch hat die Service-Öffnung konstruktiv komplett vorgesehen, das Werkzeug für die Stanzung existiert, die Position stimmt, nur ausgeliefert wird das Gerät mit zugemachter Wand und ohne Klappe. Für genau diese Lücke gibt es sogar eine eigene Ersatzteilnummer, die 00646776, und einen ganzen Zubehörmarkt. Right to Repair sieht anders aus.

Aus der Trocknerwand herausgeschnittenes Kunststoffstück an der vorperforierten Stelle der Service-Öffnung
Das herausgetrennte Stück Gehäusewand. Die Kontur war vorperforiert, ein Teppichmesser entlang der angedachten Linie genügt.

Was dann hinter der frisch geschnittenen Öffnung zum Vorschein kam, war wirklich ekelig. Die Flusen hingen als kompakter, grauer Filz komplett vor den Kühlrippen und haben den Wärmetauscher zu einem großen Teil verdeckt. So kann der Kondensator natürlich nicht mehr arbeiten, da geht schlicht keine Luft mehr durch.

Mit grauem Flusenfilz komplett zugesetzte Kühlrippen des Kondensators durch die geschnittene Öffnung
Der Blick hinter die frisch geschnittene Öffnung. Ein kompakter Flusenfilz verdeckt fast den kompletten Wärmetauscher.

Alles vorsichtig herausgeholt, abgesaugt und feucht nachgewischt. Die Lamellen waren an einigen Stellen verbogen, die habe ich anschließend so gut wie möglich wieder begradigt. Danach sah der Wärmetauscher wieder so aus, wie er soll: blankes Aluminium, freie Kanäle, Luft kann wieder hindurch.

Gereinigter Kondensator mit blanken Aluminiumlamellen und freien Luftkanälen nach dem Absaugen
Nach dem Reinigen und Begradigen der Lamellen: blankes Aluminium, freie Kanäle, die Luft kommt wieder durch.

Variante 1: die Klappe selbst drucken

Bleibt die Frage, wie man das Loch wieder sauber und vor allem dicht verschließt. Ein offenes Gehäuse zieht Falschluft und stört den Kreislauf. Ich habe eine wirklich erstklassige Druckdatei für meinen Bambu Lab X1-Carbon gefunden, ein Nachbau genau der BSH-Klappe 00646776: BSH 00646776 Condenser Door Service Panel auf MakerWorld.

Gedruckt habe ich die Abdeckung in grünem ABS und die Dichtung in TPU 95A. PLA wäre bei den Temperaturen rund um Kondensator und Wärmepumpe keine gute Idee. PLA fängt schon bei etwa 60 Grad an weich zu werden, sein Glasübergang liegt genau in dem Bereich, den so eine Baugruppe im Dauerbetrieb durchaus erreicht. Das Teil würde sich auf Dauer verziehen und die Dichtfläche verlieren. ABS liegt mit einem Glasübergang um die 105 Grad deutlich darüber und steckt die Wärme locker weg. Für die Dichtung will man dagegen etwas Flexibles, das die Spaltmaße ausgleicht und sauber abdichtet, deshalb das weiche TPU mit Shore 95A. Die Materialkombination ist hier kein Gimmick, sondern genau richtig gewählt.

Rückseite der in grünem ABS gedruckten Service-Klappe mit Versteifungsrippen und eingeprägter Teilenummer 00646776
Die in grünem ABS gedruckte Abdeckung von hinten, mit Versteifungsrippen und eingeprägter Teilenummer 00646776.

Die Versteifungsrippen auf der Rückseite und die eingeprägte Teilenummer zeigen, wie sauber die Datei gemacht ist. Mit aufgelegter TPU-Dichtung sieht das dann so aus:

Gedruckte ABS-Klappe mit aufgelegter blauer TPU-95A-Dichtung
Dieselbe Klappe mit der separat in TPU 95A gedruckten Dichtung.

Montiert wird mit sechs Schrauben in die vorhandenen Dome rund um die Öffnung. Die selbst gedruckte Abdeckung passt erstklassig und dichtet alles ab, wie man es sich wünscht.

Grün gedruckte Service-Klappe mit sechs Schrauben an der Trocknerfront montiert
Mit sechs Schrauben montiert. Die selbst gedruckte Klappe sitzt passgenau und dichtet sauber ab.

Variante 2: die fertige Klappe kaufen

Wer keinen 3D-Drucker hat, muss trotzdem nicht zum teuren Originalersatzteil greifen. Ich habe auf Amazon für knapp 15 Euro eine passende Abdeckung gefunden: Wartungsklappe für den Wärmetauscher. Die kommt aus spritzgegossenem, talkgefülltem Polypropylen (auf dem Bauteil steht die Materialkennung PP-TV30) und liegt damit thermisch ebenfalls im grünen Bereich. Im Set sind noch etwas Werkzeug zum Reinigen und sogar ein kleines Tool, mit dem sich die Kühlrippen wieder begradigen lassen.

Gekauftes Reinigungsset mit Lamellenkamm, Bürsten, Schrauben und grauer Wartungsklappe
Die gekaufte Alternative für knapp 15 Euro: Wartungsklappe plus Reinigungswerkzeug und ein kleines Tool zum Begradigen der Lamellen.

Auch diese fertige Klappe sitzt sauber in der Öffnung und schließt bündig ab. Auf dem Bauteil sind Teilenummer und Materialkennung mit eingegossen.

Graue gekaufte Wartungsklappe aus Polypropylen in der Trockneröffnung mit eingegossener Teilenummer und Materialkennung PP-TV30
Die gekaufte Klappe aus talkgefülltem Polypropylen (PP-TV30) sitzt ebenso bündig. Teilenummer und Materialkennung sind eingegossen.

Beide Lösungen, die selbst gedruckte und die gekaufte, passen perfekt und dichten zuverlässig ab. Ich kann beide vorbehaltlos empfehlen. Wer einen Drucker mit ABS-tauglichem Gehäuse hat, druckt sich die Klappe für ein paar Cent Material selbst, alle anderen sind mit der 15-Euro-Variante inklusive Reinigungswerkzeug bestens bedient.

Und jetzt?

Der Trockner läuft wieder wie am ersten Tag. Eine Ladung, ein Durchlauf, Wäsche trocken. Auf den Fotos vom verdreckten Kondensator sieht es übrigens schlimmer aus, als es sich am Ende angefühlt hat, in einer guten Stunde war alles erledigt. Schöner Nebeneffekt: Ab jetzt brauche ich kein Teppichmesser mehr, sondern schraube die Klappe einmal im Jahr auf und sauge kurz durch. Vielleicht laufen so ja noch einmal 11 Jahre aus dem Teil heraus.

Display des Bosch WTY87701 zeigt das laufende Programm Schranktrocken mit Restzeit
Läuft wieder: eine Ladung, ein Durchlauf. Das Display zeigt Schranktrocken mit normaler Restzeit.

Die kleine Moral: Lasst euch von einem „SelfCleaning“-Aufkleber nicht einlullen. Selbst die selbstreinigenden Geräte setzen sich über die Jahre zu, und ein bisschen Wartung verlängert die Lebensdauer enorm. Dass man dafür erst eine Klappe ins Gehäuse schneiden muss, die der Hersteller komplett vorbereitet, aber nicht freigibt, ist ein eigenes kleines Trauerspiel. Gut, dass es findige Leute mit Druckdateien und Ersatzteilen gibt.

Siehe auch:

Habt ihr auch so ein Gerät, bei dem die Service-Klappe ab Werk fehlt, oder eine eigene Druckdatei dafür? Dann lasst es mich gerne wissen, ihr dürft mich jederzeit fragen.

Billiger VGA-USB-Capture-Stick seziert: MS2109-Firmware, EDID-Hack und die 1080p-Lüge

Eigentlich wollte ich nur etwas ganz Simples: Aufnahmen von meinem alten DOS-Rechner machen. BIOS-POST, der Speichertest, ein paar DOS-Spiele, einmal das alles sauber als Video festgehalten. Dafür landete ein „VGA to USB 3.0 HD 1080P Video Capture Card“ in meinem Warenkorb, so ein billiger Dongle für ein paar Euro. Spoiler: in genau der Form hat das nicht geklappt. Und warum es nicht klappt, ist die eigentlich spannende Geschichte. Es geht um einen 8051 mit recyceltem Mask-ROM, eine EDID die der Quelle einen Monitor vorlügt, ein „1080p“ das horizontal gar keins ist, und eine Strings-Modifikation die ich bis heute nicht überlistet habe.

Das konkrete Gerät, falls es jemand nachvollziehen will, gibt es bei Amazon unter diesem Link. Es ist einer von hunderten optisch identischen Sticks, die alle den gleichen MacroSilicon-Chipsatz tragen. Die Erkenntnisse hier gelten also für eine ganze Geräteklasse, nicht nur dieses eine Exemplar.

Platine des VGA-USB-Capture-Sticks mit VGA-Stecker, USB-A-Buchse, Audio-Header und Silkscreen AFN_VGA_Captor
Die Platine des Sticks: VGA-Stecker, USB-A, 3-poliger Audio-Header und der Silkscreen AFN_VGA_Captor 2020/0615_V1.1.

Was steckt auf der Platine?

Aufgeschraubt zeigt sich eine winzige Platine mit Silkscreen-Aufdruck AFN_VGA_Captor 2020/0615_V1.1, einem VGA-Stecker, einer USB-A-Buchse und einem 3-poligen Audio-Header. Drei Halbleiter machen die eigentliche Arbeit:

RefChipFunktion
U3MacroSilicon MS21098051-basierte UVC-Bridge, USB 2.0 High-Speed
U7MacroSilicon MS9288AAnaloger VGA-Empfänger (PLL plus ADC plus Scaler)
U2HK / Holtek 24C16I²C-EEPROM, 2 KiB

Der MS2109 ist berühmt-berüchtigt. Er steckt in den meisten der spottbilligen HDMI-Capture-Sticks, die seit Jahren durchs Netz geistern. Hier sitzt derselbe Chip in einer VGA-Variante, mit dem MS9288A als analogem Frontend davor. Wie eng die beiden Welten verwandt sind, wird sich beim Mask-ROM-Dump zeigen: die Firmware ist nachweislich aus der HDMI-Variante recycelt.

Mikroskop-Makro des MacroSilicon MS2109, der 8051-basierten UVC-Bridge des Capture-Sticks
U3, der MacroSilicon MS2109. Der gleiche 8051-Chip wie in den billigen HDMI-Capture-Sticks, hier in der VGA-Variante.
Mikroskop-Makro des MacroSilicon MS9288A, des analogen VGA-Empfängers mit PLL und ADC
U7, der MacroSilicon MS9288A. Analoges Frontend mit PLL, ADC und Scaler, lockt das VGA-Signal und digitalisiert es.
Mikroskop-Makro des HK 24C16 I2C-EEPROM mit 2 KiB Kapazität auf der Capture-Platine
U2, der HK 24C16. 2 KiB I2C-EEPROM, hier liegen EDID, Vendor-Strings und die beiden 8051-Patch-Blöcke.

Wie meldet sich das Ding am USB?

Beim Anstecken kommt der erste Hinweis darauf, dass die Verpackung schwindelt. „USB 3.0″ steht drauf, der Kernel sieht aber ein High-Speed-Gerät, also USB 2.0:

usb 1-1.1: new high-speed USB device number 15 using xhci_hcd
usb 1-1.1: New USB device found, idVendor=534d, idProduct=2109, bcdDevice=21.00
usb 1-1.1: Manufacturer: MACROSILICON
usb 1-1.1: Found UVC 1.00 device <unnamed> (534d:2109)
hid-generic 0003:534D:2109.0009: hiddev4,hidraw8: USB HID v1.10 Device

Vendor 0x534d ist MacroSilicon, Produkt 0x2109 der nackte MS2109. Das Gerät enumeriert als UVC-1.00-Kamera plus USB-Audio plus ein vendor-spezifisches HID-Interface. Genau dieses HID-Interface ist später der Schlüssel: darüber kommt man an das EEPROM und sogar an den Arbeitsspeicher des 8051 heran, ganz ohne Lötkolben am I²C-Bus.

Das Werkzeug: ms-tools

Die zentrale Referenz für alles, was mit MS2109 zu tun hat, ist das Projekt ms-tools von Bertold Van den Bergh. Das ist eine kleine Goldgrube: EEPROM lesen und schreiben über die HID-Schnittstelle, Live-Zugriff auf den XDATA-Speicher zur Laufzeit (per eingeschleustem 8051-Patch-Code), das Werkzeug mshack zum Injizieren eigener 8051-Routinen, Ghidra-Skripte mit teildisassemblierter Firmware und ein dump-rom, das den kompletten 64-KiB-Mask-ROM ausliest.

Auf Ubuntu 24.04 ist das schnell gebaut:

sudo apt install golang-go libhidapi-dev libudev-dev
git clone https://github.com/BertoldVdb/ms-tools.git
cd ms-tools/cli && go build -o ../msctl .

Der EEPROM-Dump und eine erste Korrektur

Manche MS2109-Revisionen mögen das übliche RAM-Patching nicht, das ms-tools für komfortable Zugriffe nutzt. Mit --no-patch liest der Dump trotzdem sauber, weil dann der rohe HID-Pfad ohne vorgeschaltete Firmware-Manipulation genommen wird:

msctl --raw-path /dev/hidraw8 --no-patch read EEPROM 0 2048 --filename=dumps/eeprom-vga-raw.bin

Von den 2048 Bytes sind nur 962 belegt. Die Aufteilung sieht so aus:

OffsetGrößeInhalt
0x000 bis 0x00F16 BHeader: Magic a5 5a, dann Build-Datum 19 11 20 00 = 2019-11-20
0x010 bis 0x02F32 BVendor-Strings im Pascal-Format: AFN_Cap video, AFN_Cap audio
0x030 bis 0x07974 B8051-Patch-Code #1 (ROM-Hook-Routinen)
0x07A bis 0x179256 BVollständige EDID (128 Base plus 128 CTA-861-Extension)
0x17A bis 0x3C1584 B8051-Patch-Code #2, Keil-C51-kompiliert

Die ersten Bytes mit dem Hexeditor angeschaut, da steht die Geschichte schon drin:

0000  a5 5a 03 8e 09 10 ff ff ff ff ff ff 19 11 20 00   .Z............ .
0010  0e 41 46 4e 5f 43 61 70 20 76 69 64 65 6f ff ff   .AFN_Cap video..
0020  0e 41 46 4e 5f 43 61 70 20 61 75 64 69 6f ff ff   .AFN_Cap audio..
0030  90 de 07 ef f0 12 cd d6 90 de 07 e0 ff 02 cf 25   ...............%

Das 0e vor jedem String ist die Pascal-Längenangabe (14 Zeichen). Ab 0x30 beginnt der erste 8051-Patch-Block. Spannend ist die EDID-Sektion ab 0x07A. Mein erster Analyse-Versuch ging davon aus, der klassische EDID-Header 00 FF FF FF FF FF FF 00 sei wegoptimiert und werde erst zur Laufzeit ergänzt. Das war falsch. Nach dem Mask-ROM-Dump und einem genaueren Blick steht der Header sauber im EEPROM. Solche Korrektur-Momente sind mir lieber als ein zu glattes Narrativ, also schreibe ich sie hier auch hin.

Die EDID selbst enthält einen Monitor-Namen MACROSILICON (EDID-Descriptor mit Tag 0xFC), als erstes Detailed-Timing 1280×720 bei 60 Hz mit 74,25 MHz Pixeltakt, ein zweites Timing 1280×768, dazu eine CTA-861-Extension mit nativem VIC=4 (720p60) plus VICs für 1080p60, 1080i, 720p50, 480p und 576p. Es gibt sogar einen HDMI-VSDB-Block mit dem OUI 00:0C:03 von HDMI Licensing und einer Source Physical Address. Eine VGA-only-Platine, die HDMI-Strukturen in ihrer EDID trägt. Das ist schon der erste deutliche Fingerabdruck der gemeinsamen Codebasis.

Der Mask-ROM verrät die Herkunft

Der eigentliche Programmcode des 8051 liegt in einem nicht beschreibbaren Mask-ROM. dump-rom lädt dafür eigenen 8051-Code in den USERRAM, der per MOVC den ROM ausliest und über HID zurückschiebt:

msctl --raw-path /dev/hidraw8 --no-patch dump-rom dumps/maskrom-vga.bin

64 KiB komplette CODE-Memory, davon rund 36 KiB belegt im Bereich 0x0000 bis 0x8FFF und weitere 12 KiB ab 0xC000. Die Highlights:

  • 0x7738: eine zweite, im ROM fest verdrahtete Default-EDID, intakt mit Standard-Header, Hersteller-ID „HJW“ und Monitor-Name „HDMI TO USB“. Das ist der Beweis: diese Firmware wurde ursprünglich für den HDMI-Bruder geschrieben.
  • 0x77A9: der ASCII-String „HDMI TO USB“ noch einmal direkt.
  • 0x7087 und 0x709B: die USB-String-Descriptor-Fallbacks „USB Video“ und „USB Digital Audio“ in UTF-16LE. Diese beiden werden uns gleich noch ärgern.
  • 0x0000: der Reset-Vector 02 41 49, also LJMP 0x4149.
  • Ein LCALL 0xCC10 bei ROM-Adresse 0x478F: das ist der zentrale Einstieg vom Mask-ROM in den EEPROM-Patch-Code, der zur Laufzeit in den RAM kopiert wurde.

Der Bootloader kopiert dabei die EEPROM-Bytes ab Offset 0x30 in den USERRAM ab Adresse 0xCBD0. Die Strings-Sektion 0x10 bis 0x2F wird explizit nicht mitkopiert. Die Patch-Firmware liest die Strings stattdessen zur Laufzeit direkt per I²C aus dem EEPROM und baut daraus die USB-String-Descriptoren an festen RAM-Adressen zusammen. Diese Mapping-Tabelle ist der Kern, um den herum sich die ganze Bastelei dreht:

EEPROMCode-RAMWas
0x0300xCC00Patch #1, kleine Hook-Routinen
0x07A0xCC4AEDID-Header 00 FF FF FF FF FF FF 00
0x0800xCC50EDID-Body
0x0FA0xCCCACTA-861-Extension
0x17A0xCD4APatch #2, C51-Startup
0x1800xCD50MOV SP,#0x3B; LJMP 0xCD91
0x1C10xCD91main() der Patch-Firmware

Erkenntnis 1: Die EDID lügt der Quelle einen Monitor vor

Über die DDC-Leitungen am VGA-Stecker (Pins 12 und 15) präsentiert der Dongle dem Quell-PC eine EDID. Damit gibt sich das Gerät als Monitor namens „MACROSILICON“ mit 720p60 als nativer Auflösung aus. Genau deshalb liefert ein Quell-PC, an dem gar kein echter Monitor hängt, trotzdem ein sinnvolles Bild: er glaubt, ein 720p-Display gefunden zu haben. So weit, so clever.

Erkenntnis 2: Das „1080p“ ist Marketing

Die UVC-Frame-Tabelle im Mask-ROM listet zwar brav 1920×1080 mit 30 fps als MJPEG. Die Realität sieht anders aus. Mit v4l2-ctl lässt sich die Format-Liste auslesen:

$ v4l2-ctl -d /dev/video2 --list-formats-ext
[0]: 'MJPG' (Motion-JPEG, compressed)
  Size: Discrete 1920x1080    30/25/20/10/5 fps
  Size: Discrete 1280x720     60/50/30/20/10 fps
  Size: Discrete 1024x768     60/50/30/20/10 fps
[1]: 'YUYV' (YUYV 4:2:2)
  Size: Discrete 1920x1080    5 fps
  Size: Discrete 640x480      30/20/10/5 fps

Unkomprimiertes YUYV bei 1080p ist auf magere 5 fps gedeckelt. Das native Detailed-Timing der EDID ist 720p60, das 1080p wird intern hochskaliert. Und USB 3.0 ist nirgends, der Chip kann nur High-Speed. Drei Behauptungen auf der Verpackung, drei mal geschummelt. Auf die 5-fps-Grenze komme ich am Ende noch genauer zurück, die hat einen sehr konkreten technischen Grund.

Erkenntnis 3: Kein DOS, und das lässt sich nicht reparieren

Jetzt zum eigentlichen Frustpunkt, der Grund, warum mein DOS-Plan scheiterte. Die UVC-Frame-Tabelle enthält keine 70-Hz-Modi. Ein DOS-BIOS gibt aber im Standard-VGA-Textmodus 720×400 bei 70 Hz aus. Das analoge Frontend, der MS9288A, könnte dieses Signal vermutlich locken, der Horizontaltakt von 31,469 kHz ist derselbe wie bei 640×480 bei 60 Hz. Aber die MS2109-Firmware bietet schlicht keinen passenden UVC-Frame-Mode an, an dem ein Aufnahmeprogramm andocken könnte.

Und das Bittere: im EEPROM kann man das nicht reparieren. Die Frame-Tabelle liegt im nicht-flashbaren Mask-ROM. Das EEPROM steuert nur EDID, Strings und ein paar Patch-Routinen, nicht die Liste der angebotenen Auflösungen. Wer mit so einem Dongle echte DOS-Signale aufnehmen will, kommt um eine vorgeschaltete Scaler-Box nicht herum. Mehr dazu am Ende.

Was wir trotzdem geändert haben: die EDID

Wenn die Frame-Tabelle schon unantastbar ist, dann wenigstens die EDID anfassen. Ich habe das erste Detailed-Timing von 720p60 auf 1920×1080 bei 60 Hz umgeschrieben (148,5 MHz Pixeltakt, das Standard-CEA-861-1080p60-Timing), die EDID-Checksumme neu berechnet und das EEPROM zurückgeflasht. Das neue DTD #1 sieht so aus:

02 3a 80 18 71 38 2d 40 58 2c 45 00 00 00 00 00 00 1e

Nach einem Replug liefert das Gerät die modifizierte EDID. Zumindest dachte ich das. Ob sie auch wirklich bei einer Quelle ankommt, war eine ganz eigene Odyssee, dazu komme ich beim A/B-Test. Vorweggenommen: Die Mod tut technisch genau das, was sie soll, sie ändert nur am Ende nichts an dem, was aufgenommen wird. Eine reine „ich sage der Quelle, ich kann 1080p“-Kosmetik.

Was NICHT geklappt hat: die Strings

Das ist der lehrreichste Teil des Projekts, gerade weil er bis heute offen ist. Ich wollte die USB-Function-Strings ändern, aus dem hässlichen „AFN_Cap video“ sollte ein sauberes „VGA Capture HD“ werden. Das EEPROM-Schreiben verifiziert sauber per Readback-Hash. Die EDID-Mod aus demselben Reflash funktioniert. Aber die Strings fallen nach dem Replug auf die ROM-Defaults „USB Video“ und „USB Digital Audio“ zurück. Irgendetwas in der Firmware sagt „nein“.

Also empirisch rangegangen, drei Single-Byte-Tests in der Strings-Region 0x10 bis 0x2F: einmal ein ASCII-Zeichen geändert, einmal ein FF-Padding-Byte, einmal das Längen-Byte. Jeder einzelne dieser Eingriffe löst den Fallback aus. Egal welches Byte, egal welcher Inhalt. Das ist eine klare Indikation für ein Content-Gate über die gesamte 32-Byte-Region, nicht für eine simple Längen- oder Inhaltsprüfung an einer Stelle.

Ein Gegencheck zur eigenen Analyse hat ein paar Punkte geschärft. Die Bytes 0x02 und 0x03 (03 8E) sind verifiziert die Payload-Länge und keine Checksumme: 910 Bytes ab 0x30 reichen bis 0x3BD, exakt bis vor den End-Marker. Der Verdacht: ein lokales Validierungs-Gate auf den 32-Byte-Strings-Slots, wahrscheinlich ein Hash oder ein Exact-Content-Compare in einem ROM-Helper, nicht im Patch-Code selbst. Ein Quervergleich mit dem usbkvm-Projekt zeigt dasselbe Header-Muster bei verwandter Hardware.

Der spannendste Nebenbefund kam aus einem XDATA-Boot-Trace bei 0xC630 bis 0xC67F: die „AFN_Cap“-Strings landen gar nicht dauerhaft im RAM. Was dort steht, ist die Fallback-Variante. Die echten Strings werden erst zur Laufzeit beim USB-GetDescriptor aus dem EEPROM gebaut, und genau in diesem Moment greift offenbar das Gate. Wer das knacken will, müsste den USB-Control-Transfer mit usbmon und Wireshark während der Enumeration mitschneiden, die ROM-Helper bei 0x6345 und Nachbarn disassemblieren, oder mit mshack einen Bypass-Patch auf den Fallback-Branch setzen. Der schnellste Weg wäre allerdings, ein vom offiziellen MacroSilicon-Windows-Tool editiertes EEPROM byteweise gegen mein eigenes zu diffen. Dieses Tool schreibt vermutlich versteckte Metadaten mit, die das öffentliche Reverse-Engineering noch nicht dokumentiert hat. Das ist mein heißester Kandidat fürs Strings-Mysterium, aber bisher unbewiesen.

Der A/B-Test, oder: die Suche nach einer ehrlichen VGA-Quelle

Ich wollte zwei Dinge empirisch klären. Erstens: Kommt die modifizierte 1080p-EDID überhaupt bei der Quelle an? Zweitens: Löst das Ding echtes 1080p auf oder ist das hochskalierter Matsch? Beides braucht eine VGA-Quelle, die ihre DDC-Leitung auch wirklich ausliest. Das war schwerer zu finden als gedacht.

Erster Anlauf, ein Notebook mit „VGA-Out“. Plot-Twist: der VGA-Anschluss lief über einen aktiven DisplayPort-auf-VGA-Adapter und tauchte als DP-4 auf. Der Adapter liefert ein eigenes synthetisches EDID („NVD“, „LCD_VGA“, 1280×1024). Das ist nicht das EDID aus dem Dongle. Der EDID-Pfad vom Dongle bis zur GPU ist also unterbrochen. Erste Lehre: aktive DP-auf-VGA-Adapter verhalten sich wie eine eigene EDID-Quelle und maskieren alles dahinter.

Zweiter Anlauf, ein ThinkPad L560 mit echter VGA-Buchse hinten. Wieder gescheitert, und zwar aus einem prinzipiellen Grund. Ab Haswell und Skylake hat Intel den analogen RAMDAC komplett aus der GPU geworfen. Die physische VGA-Buchse hängt an einem Onboard-DP-auf-VGA-Bridge-Chip. Der Kernel verrät es selbst:

$ xrandr --verbose | grep -i subconnector
        subconnector: VGA
$ cat /sys/class/drm/card1-DP-2/edid | wc -c
0

Null Byte EDID. Die Bridge reicht das DDC vom Dongle nicht durch, der Connector bietet nur die VESA-Default-Modi an. Exakt dasselbe Problem wie beim ersten Notebook, nur diesmal onboard statt als Dongle. Auf beiden getesteten Bridge-Pfaden kam die EDID des Capture-Sticks nicht zur Quelle durch. Als Trend lässt sich sagen: neuere Intel-Notebooks haben den analogen RAMDAC verloren (der fiel etwa um Haswell und Broadwell, also rund 2013 bis 2015) und führen „VGA“ über DP-Bridges, die DDC abschneiden. Das als „kein Notebook taugt je“ zu verkaufen wäre übertrieben, es ist ein Trend, kein Beweis.

Ein netter Nebenbefund vom L560, den ich erst für einen kaputten Aufbau hielt: nach jedem Auflösungswechsel liefert der Dongle erstmal 1 bis 6 Sekunden ein komplett schwarzes Bild, bis die MS9288A-PLL wieder eingerastet ist. Ich hatte reihenweise schwarze Frames gegrabbt und schon den Analogpfad für tot erklärt. In Wirklichkeit hatte ich nur immer ins Re-Lock-Fenster reingeschossen. Mit 4 bis 6 Sekunden Settle-Zeit kommt das Bild stabil. Passt zur dokumentierten Sync-Trägheit des Chips.

Der A/B-Test selbst

Trotz blockiertem DDC habe ich den A/B durchgezogen, einfach um es schwarz auf weiß zu haben. Als Quelle ein 1920×1080-Testbild mit vier Quadranten aus 1-Pixel-Gittern: Schachbrett, vertikale Linien, horizontale Linien, Diagonale. Dazu grüne Eck-Marker, um sicher zu sein, dass der ganze Frame ankommt.

1920x1080-Testbild mit vier Quadranten aus 1-Pixel-Gittern und grünen Eck-Markern für den A/B-Test
Das Quell-Testbild: vier Quadranten mit 1px-Schachbrett, Vertikal-, Horizontal- und Diagonalgittern, dazu Eck-Marker.

Erst der Befund, der mich am meisten interessiert hat: 1px-Vertikallinien und Schachbrett kollabieren zu flachem Grau, das horizontale Feindetail ist weg. 1px-Horizontallinien und Diagonale zeigen schwache Resttextur. Die vertikale Auflösung kommt also weitgehend durch (jede Scanline ist eine eigene analoge Zeile), die horizontale ist deutlich begrenzt. Wichtiges Ehrlichkeits-Caveat: das ist die komplette Analogkette, also Bridge-DAC im L560, Kabel und MS9288A-ADC zusammen. Mit diesem Aufbau lässt sich nicht sauber trennen, wieviel davon der Dongle ist.

Dann der eigentliche EEPROM-A/B. EEPROM gegen das Original-Backup getauscht, Readback-Hash jedes Mal verifiziert. Etwas, das ich erst lernen musste: nach dem Flashen muss der Dongle einmal physisch ab- und wieder angesteckt werden. Ein reiner USB-Bus-Reset reicht nicht, der 8051 läuft einfach weiter und liest das neue EEPROM gar nicht ein. Software-seitig ging das nicht (kein schaltbarer Port), also von Hand. Die Zahlen, ImageMagick MAE auf einer Skala von 0 bis 1:

VergleichMAEpro 255
Rauschen mod3 (zwei Aufnahmen, gleiches EEPROM)0,00411,05
Rauschen Original0,00431,10
mod3 gegen Original0,00711,82

Der Cross-Wert liegt nur minimal über dem Rauschpegel. Das maximal verstärkte Differenzbild zeigt keine strukturierte Änderung, nur MJPEG-Blockrauschen in den hochfrequenten Quadranten. Die kleine Differenz ist Re-Lock- und AGC-Varianz zwischen den Sessions, nicht das EDID.

Maximal verstärktes Differenzbild mod3-EDID gegen Original-EDID, nur MJPEG-Blockrauschen, keine strukturierte Änderung
Maximal verstärkte Differenz: mod3-EDID gegen Original-EDID. Nur Blockrauschen, keine Struktur. Die EDID-Mod ändert am Bild nichts.

Fazit des A/B: Die EDID-Mod von 720p auf 1080p verändert das aufgenommene Bild exakt null. Aus erstem Prinzip war das klar, die EDID ist das, was das Gerät der Quelle anbietet, sie steuert nicht den Aufnahmepfad. Aber jetzt steht es empirisch da.

Endlich eine echte Analog-Quelle, und zwei dicke Befunde

Dritter Anlauf, diesmal ein Desktop mit einer NVIDIA GeForce GT 630 (Kepler). Kepler hat noch einen echten analogen RAMDAC, über einen passiven DVI-I-auf-VGA-Adapter kommt echtes analoges VGA raus. Endlich die Hardware, die den Notebooks fehlte.

Befund 1: Die EDID-Mod ist sogar grundsätzlich für die Katz. Der NVIDIA-Treiber cached die analoge EDID hartnäckig (analoges VGA hat kein Hotplug), also einmal lightdm neugestartet für einen frischen Read. Das Ergebnis im Xorg-Log:

(--) NVIDIA(GPU-0): CRT-0: connected
(WW) NVIDIA(0): CRT-0 does not have an EDID

Der Dongle liefert auf seinem VGA-Eingang gar keine DDC und keine EDID. Sauber belegt: derselbe Adapter hat vorher die EDID eines echten Monitors gelesen (ein Fujitsu P24-9, mit 1920×1080 und physischen Maßen), der Adapter reicht DDC also durch. Es ist der Dongle, der nichts treibt. Das kippt eine Annahme aus meinen eigenen Notizen, wo stand, das Patch-Modul beantworte „wahrscheinlich“ DDC-Reads. Dieses „wahrscheinlich“ war nie gemessen.

Ehrlich bei der Konfidenz bleiben: hoch dafür, dass in diesem GT-630-Test keine lesbare EDID vom Dongle kam. Nur mittel für das universelle „keine Quelle sieht die EDID je“. Restzweifel, die ich noch nicht ausgeräumt habe: NVIDIA-Eigenheiten bei analogem DDC, ob die Dongle-DDC die 5 Volt auf VGA-Pin 9 braucht, oder ob der DDC-Responder erst nach Sync-Lock aufwacht. Ein Pin-9-Check plus Logic-Analyzer auf den Pins 12 und 15 (mit dem Fujitsu als Positiv-Kontrolle) würde es hart machen. Arbeitshypothese: auf dem getesteten echten Analogpfad präsentierte der Dongle keine lesbare EDID, vermutlich weil der VGA-DDC-Pfad in der geteilten HDMI/VGA-Codebasis schlicht nicht verdrahtet ist. HDMI hat HPD und DDC, VGA hier offenbar nicht.

Befund 2: Das „1080p“ ist vertikal echt, horizontal Fake. Mit echtem RAMDAC konnte ich endlich den Dongle-Anteil am Blur isolieren (eigene xorg.conf mit UseEDID false und forciertem 1080p-Modeline, NVIDIA lehnt xrandr-Modelines ab). Testbild: reine 1px-Schwarz-Weiß-Gitter per xsetroot. Das Resultat, gemessen als Standardabweichung der Luma (ein ideales Gitter liegt bei rund 128, flaches Grau bei 0):

1px-Gitter bei echtem Analog-1080pStd-Abw.Bedeutung
horizontale Linien119vertikal sauber aufgelöst
vertikale Linien0,6horizontal komplett verschmiert
Schachbrett0,55weg
Zweifach-Zoom dreier Ausschnitte: vertikales Gitter grau verschmiert, horizontales Gitter scharf, Schachbrett grau
Echte Analog-Quelle (GT 630): links das vertikale 1px-Gitter zu Grau verschmiert, mittig horizontale Linien gestochen scharf, rechts das Schachbrett weg.

Der Dongle hat also echte 1080 Zeilen vertikal, aber horizontal kommt weit weniger als 1920 Pixel an. Und weil hier kein Bridge mehr dazwischen sitzt: der Blur ist der Dongle selbst, nicht das billige Notebook. Das ist die belastbare Aussage.

Den Mechanismus habe ich auf einen Vorschlag aus dem Gegencheck hin gleich nachgemessen, ein Phasen- und Balkenbreiten-Test. Das 1px-Gitter ist bei Phase 0 und Phase 1 gleich grau (Std-Abw. 0,18 gegen 0,19), ein PLL- oder Phasen-Mislock ist damit ausgeschlossen, sonst würde der 1px-Versatz den Kontrast kippen. Und der Kontrast steigt sauber mit der Balkenbreite: 1px liegt bei rund 0, 2px bei 35, 3px bei 61, 4px bei 79 (Ideal rund 128). Das ist die klassische Tiefpass-Signatur, also ein echtes Horizontal-Auflösungs-Limit und kein Lock-Artefakt. Ob das an der ADC-Abtastrate oder an einem internen Rescale liegt, kann ich noch nicht trennen, aber beides heißt: der Dongle begrenzt die Horizontalauflösung.

Auflösungs-Rampe von 1px bis 4px Balkenbreite, der Kontrast steigt von flachem Grau zu klaren Streifen
Balkenbreiten-Rampe: 1px bleibt grau, ab 2px wird Kontrast sichtbar. Klassische Tiefpass-Signatur, ein echtes Horizontal-Limit.

Warum YUYV bei 1080p nur 5 fps macht

Zum Schluss noch die berüchtigte 5-fps-Grenze, diesmal gemessen statt aus der Tabelle abgeschrieben (Quelle GT 630 bei 1080p60, v4l2-ctl --stream-mmap):

Format bei 1920×1080gemessen
MJPEG29 fps (Tabelle: 30)
YUYV (unkomprimiert)exakt 5,0 fps

Meine erste Erklärung mit „ungefähr 40 MB/s USB-2.0-Bandbreite“ war zu schludrig. Der genaue Grund ist das Payload-Limit des isochronen UVC-Endpoints. Das größte Altsetting des Video-Endpoints ist 3 mal 1024 Bytes pro Microframe, also 24,576 MB/s (steht so im lsusb -v). Unkomprimiertes YUYV bei 1080p sind 1920 mal 1080 mal 2 Bytes, also 4,15 MB pro Frame. Die Firmware-Frametabelle ist genau so gewählt, dass die YUYV-Raten knapp darunter passen:

  • 1080p mal 5 fps = 20,7 MB/s
  • 720p mal 10 fps = 18,4 MB/s
  • 480p mal 30 fps = 20,7 MB/s

Also keine generische „USB-2.0-Bandbreite“, sondern der High-Speed-Isoch-Endpoint plus die fest verdrahteten Frametabellen. MJPEG komprimiert vorher, deshalb bleiben dort 30 fps bei 1080p und bis zu 60 bei 720p und darunter. Klare Ansage: am MS2109 ist MJPEG bei hoher Auflösung Pflicht. Und „USB 3.0″ auf der Verpackung ist und bleibt gelogen, das Ding enumeriert als USB-2.0-High-Speed, ohne SuperSpeed.

Stabiles Device-Naming per udev

Damit das Gerät im Alltag immer unter demselben Pfad auftaucht, eine udev-Regel. Die Regeln stehen bewusst jeweils auf einer Zeile, weil mehrzeilige Fortsetzungen mit Backslash schnell zur Fehlerquelle werden:

# /etc/udev/rules.d/70-vga-capture-ms2109.rules
SUBSYSTEM=="video4linux", ENV{ID_VENDOR_ID}=="534d", ENV{ID_MODEL_ID}=="2109", ENV{ID_V4L_PRODUCT}=="*AFN_Cap*", ATTR{index}=="0", SYMLINK+="video-vga"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="534d", ATTRS{idProduct}=="2109", ATTRS{product}=="USB3.0 Capture", GOTO="ms2109_vga_end"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="534d", ATTRS{idProduct}=="2109", SYMLINK+="hidraw-vga", MODE="0660", GROUP="plugdev"
LABEL="ms2109_vga_end"

Die GOTO-Konstruktion ist nötig, weil ATTRS{product}!="..." nicht so funktioniert, wie man denkt. udev sperrt die Attribut-Suche auf einen Parent-Device-Walk, und „Fehlen eines Attributs“ verhält sich anders als ein echtes Ungleich. Deshalb der Umweg über ein positives Match plus Sprungmarke.

Fazit, und der Workaround der wirklich hilft

Was bleibt? Der Dongle ist ein nettes Studienobjekt, aber für mein ursprüngliches Ziel, alte DOS-Signale aufnehmen, ist er unbrauchbar. Es gibt keine 70-Hz-Modi, und reparieren lässt sich das nicht, weil die Frame-Tabelle im Mask-ROM festgebrannt ist. Das „1080p“ ist horizontal Marketing, „USB 3.0″ eine glatte Lüge, und die EDID-Mod ist auf dem getesteten Analogpfad wirkungslos, weil der Dongle gar keine EDID treibt. Drei Korrektur-Momente gegenüber meinen ersten Annahmen, alle drei lehrreicher als wenn alles gleich funktioniert hätte.

Wer wirklich exotische VGA-Signale wie DOS-Textmodi sauber aufnehmen will, schaltet eine externe Scaler-Box vor. Mein Tipp ist GBS-Control, eine offene Firmware für die rund 20 Euro teuren GBS-8200-Boards. Sie nimmt das krumme Quellsignal, lockt sauber und gibt ein normgerechtes Bild aus, das jeder Capture-Stick frisst. Die andere Liga ist der Open Source Scan Converter, dessen Firmware-Update ich hier schon beschrieben habe. Wer es noch günstiger mag, fängt einen gebrauchten Extron-Scaler aus einer Konferenzraum-Ausmusterung.

Offen bleibt das Strings-Mysterium (ohne das Vendor-Tool komme ich an den Gate-Algorithmus nicht ran), die Disassembly des zweiten Patch-Blocks in Ghidra, ein Latenz-Benchmark für den Einsatz als KVM-Console-Viewer, und der finale DOS-Test mit GBS-Control davor. Material für einen zweiten Teil ist also reichlich da.

Siehe auch:

Habt ihr selbst schon so einen MS2109-Stick auseinandergenommen oder das Strings-Gate geknackt? Dann lasst es mich gerne wissen, ihr dürft mich jederzeit fragen.

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

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

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

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

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

Warum überhaupt, und warum jetzt

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

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

Phase 0: Machbarkeitscheck mit Scope-Disziplin

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

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

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

Stack-Entscheidungen

Die wichtigsten Bauklotz-Entscheidungen in Stichworten:

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

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

Neun Iterationen in zwei Tagen, jede mit echtem Erkenntnisgewinn

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

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

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

Methodisch: drei Dinge die den Unterschied gemacht haben

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

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

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

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

Wie das für einen Mastodon-User aussieht

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

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

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

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

Was geplant ist, und was bewusst draußen bleibt

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

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

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

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

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

Ehrliche Einordnung

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

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

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

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

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

Mein Arbeitskollege Michael lässt eine AI meinen Blog roasten

Moin.

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

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

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

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

Der Roast, in voller Länge

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

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

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

Die Tagline: „Privater Tech-Blog seit 2003“

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

Die Hausordnung

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

Und mein absoluter Favorit:

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

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

Die Self-Hosting-Flex

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

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

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

Post-Quantum-Kryptographie

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

Der DNS-Resolver

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

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

Die Credentials-Flex

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

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

Die Blog-Post-Titel

Lass uns das mal durchgehen, das ist Kunst:

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

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

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

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

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

„Versteckte Easter Eggs“

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

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

Das Theme-Detail

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

Die Sprachebene: „Moin!“

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

Das übergreifende Problem

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

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

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

Zusammenfassung

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

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

🫡

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

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

Danke, Michael

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

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

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

LCR-T4-Plus v2: m-firmware flashen, Display-Tuning und die 8-MHz-Quartz-Falle

Vor ein paar Wochen habe ich hier den TC1 Multifunction Tester mit der quelloffenen m-firmware geflasht. Seitdem liegt ein zweiter Bauteiltester aus genau dem gleichen Dunstkreis bei mir auf dem Tisch: ein LCR-T4-Plus mit gelber Platine, eingebautem ZIF-Sockel und Beschriftung „91make.taobao.com“ auf der Rückseite. Das billige Gegenstück zum TC1, vom selben Hersteller-Cluster, gleiche Firmware-Familie. Eigentlich ein gemütlicher Folge-Sonntag, dachte ich.

Aufgeklappter LCR-T4-Plus v2 mit ZIF-Sockel, blauem Start-Button und ISP-Header mit vertauschten Silkscreen-Labels
Ausgangslage: aufgeklapptes Display, ZIF-Sockel, blauer Start-Button. Rechts vom Button der ISP-Header mit den (falsch beschrifteten) Silkscreen-Labels.

Zur Vorgeschichte gehört ein erster T4-Plus, den ich vor Jahren mal gekauft hatte. Das Gerät arbeitet bis heute, sein Display allerdings ist tot. Der COG-Controller unter dem Epoxy-Klecks auf dem ST7565R hat aufgegeben, der MCU lebt noch, aber zeigen kann er nichts mehr. Vor ein paar Wochen habe ich aus einer Resterampe ein zweites Exemplar geordert, gleicher Aufdruck, gleiche PCB-Revision. Plan: einmal Backup-Flash und dann beide Geräte parallel mit m-firmware betreiben, eines als Bastelreserve.

Aus „schnell mal das gleiche Flash-Profil wie beim ersten T4-Plus drüberbügeln“ wurde ein ganzer Nachmittag mit komplett schwarzem Display, abgeschnittenem Cursor, einem Tester der sich beim Loslassen des Buttons sofort wieder ausschaltet und am Ende der Erkenntnis, dass die zentrale Falle nicht in der Firmware steckt, sondern in einem winzigen Bauteil neben dem MCU.

Was steckt im T4-Plus v2?

Anders als der TC1 mit seinen zwei Chips (ATmega plus STC für das Power-Management) ist der T4-Plus ein Single-MCU-Design. Auf der Rückseite klebt genau ein nennenswerter Halbleiter, der Rest ist Spannungsteiler, drei Transistoren für die Power-Latch-Schaltung und der Quartz.

PCB-Rückseite des T4-Plus v2 mit ATmega328P, 8-MHz-Quartz und 9V-Block-Anschluss
Rückseite des PCB mit dem ATmega328P, dem silbernen Quartzblock daneben (8 MHz, nicht 16 wie beim ersten Exemplar!) und dem 9V-Block-Anschluss.
Mikroskop-Closeup auf den ATmega328P-U-TH mit Date-Code 2009SG8
Close-up unter der Lupe: ATMEL MEGA328P-U-TH, Date-Code 2009SG8. Originalsilizium von Atmel/Microchip, kein LGT8F328-Klon wie auf manchen jüngeren Boards.

U1: ATmega328P-U-TH im TQFP-32-Gehäuse, Signatur 0x1e950f, 32 KB Flash, 2 KB SRAM, 1 KB EEPROM. Der Date-Code 2009SG8 stammt aus 2020, Production-Code-Pattern passt zu echtem Microchip-Silizium. Auf gefälschten Klonen sieht das Lasermarkings-Pattern deutlich anders aus, das Schriftbild ist hier sauber, also passt das.

Der Display-Controller sitzt unter dem schwarzen Epoxy-Blob auf dem LCD selbst und ist ein ST7565R mit 128 mal 64 Pixel monochromem STN-Panel, gelb-grüne Hintergrundbeleuchtung. Klassische COG-Bauform. Daten kommen seriell über vier GPIO-Leitungen rein. Kein I2C, sondern SPI-Bitbang vom MCU getrieben.

Stromversorgung läuft über einen 9V-Block mit Spannungsteiler 10k zu 3.3k auf einem ADC-Pin. Bedienelement: ein einziger blauer Start-Button, kein Drehgeber, kein IR-Empfänger, kein USB. Das Ding ist ein Wegwerf-Standalone im besten Sinne, nichts dran was schiefgehen kann.

Und dann ist da noch dieser kleine silberne Quartzblock direkt neben dem MCU. 8.000 steht da. Acht Megahertz. Beim ersten T4-Plus von vor Jahren habe ich 16 MHz im Hinterkopf, ich notiere mir die 8 MHz pflichtbewusst, denke aber „nett, anderer Quartz“ und mache ansonsten erstmal weiter. Spoiler: genau dieser Eintrag wird Stunden später zum Schlüssel.

ISP-Header finden, und der Aufdruck lügt

Der ISP-Header sitzt versteckt unter der Display-Platine, ein simples 2×3-Pad-Grid ohne aufgelötete Stiftleiste. Auf der Rückseite finden sich Silkscreen-Hinweise: mis, mosi, sck, reset, plus die beiden Stromversorgungspads. Lustig: bei diesem Board sind die beiden Datenleitungen vertauscht beschriftet. Was als mis markiert ist, trägt physisch MOSI; das mosi-Pad ist tatsächlich MISO.

Aufgefallen ist mir das beim ersten Signatur-Read mit dem Arduino Uno als ISP-Programmer. Wenn man stur nach Silkscreen verkabelt, antwortet der Chip mit lauter 0x00. Sobald die Leitungen getauscht werden, kommt eine saubere Signatur. Heißt für die Praxis: bei diesem Board nicht auf den Aufdruck verlassen, sondern Pin für Pin mit dem Durchgangsprüfer gegen die Chip-Pins verifizieren. Standard-ISP-Pinout auf dem ATmega328P im TQFP-32 ist Pin 17 MOSI, Pin 18 MISO, Pin 19 SCK, Pin 29 /RESET.

Arduino Uno als ISP-Programmer am ISP-Header des T4-Plus v2
Arduino Uno mit ArduinoISP-Sketch als Programmer. Display zur Seite geklappt, vier Datenleitungen plus 5V und GND. Genauso wie beim TC1, gleiche Hardware, gleiche Flag-Kombination.

Verkabelung an den richtigen Pads (Arduino-seitig), gilt für beide T4-Plus-Boards:

Arduino D10  ->  RESET   (T4-Plus Pad RESET)
Arduino D11  ->  MOSI    (T4-Plus Pad „mis"   bei diesem Klon!)
Arduino D12  ->  MISO    (T4-Plus Pad „mosi"  bei diesem Klon!)
Arduino D13  ->  SCK     (T4-Plus Pad SCK)
Arduino 5V   ->  VCC
Arduino GND  ->  GND

Sobald die Signatur sauber kommt, ist die halbe Miete drin:

$ avrdude -c avrisp -p m328p -P /dev/ttyACM0 -b 19200 -B 32 -v 2>&1 | grep -i sig
avrdude: Device signature = 0x1e950f (probably m328p)

Backup der Original-Firmware

Beim TC1 ging das Backup nicht, weil die Lock-Bits auf 0xC0 standen und das Auslesen geblockt haben. Beim T4-Plus v2 habe ich Glück: Lock-Byte ist 0xFF, also komplett offen. Vor dem ersten Flash zieht man sich also bitte unbedingt das Original einmal komplett herunter, Flash und EEPROM:

avrdude -c avrisp -p m328p -P /dev/ttyACM0 -b 19200 -B 32 
        -U flash:r:t4plus2-original-flash.hex:i 
        -U eeprom:r:t4plus2-original-eeprom.hex:i 
        -U lfuse:r:-:h -U hfuse:r:-:h -U efuse:r:-:h -U lock:r:-:h

Fuses kamen so zurück: lfuse=0xF7, hfuse=0xD9, efuse=0xFD, lock=0xFF. Hat man das im Kasten, kann man auch beruhigt herumprobieren. Notfalls geht es per avrdude -U flash:w: jederzeit wieder auf den Auslieferungszustand zurück.

Erster Flash und ein völlig schwarzes Display

Quelle für die neue Firmware ist wie beim TC1 die m-firmware von madires, aktuell Version 1.56m. Im Repo liegt unter Software/Firmware/m-firmware/ der Source mit allen Config-Templates für die diversen MCU-Varianten. Für den ATmega328P ist das config_328.h, gemeinsame Optionen in config.h, build-Flags im Makefile.

Mein erster Anlauf orientierte sich an dem, was beim ersten T4-Plus damals funktioniert hatte. Display ST7565R aktivieren, BAT_DIVIDER mit 10k zu 3.3k, Short-Circuit-Menu an, MCU auf atmega328 (ohne das nachgeschobene P, sonst greift der #if defined(__AVR_ATmega328__)-Guard in config_328.h nicht und der Build wirft undefinierte BUTTON_PIN-Symbole), Frequenz pflichtbewusst auf FREQ = 16 wie beim ersten Board.

Make, flash, einschalten, und dann das hier:

Komplett dunkles ST7565R-Display nach erstem Flash mit Default FLAG_RATIO_65
Display nach erstem Flash mit Default-FLAG_RATIO_65: komplett dunkel. Klassisches Symptom eines zu hoch eingestellten internen LCD-Spannungsteilers.

Komplett schwarz. Kein Boot-Banner, keine Kontrast-Stufe, einfach nur eine schwarze Fläche. Mein erster Gedanke war natürlich: Display kaputt, gleicher Spaß wie beim ersten T4-Plus. Aber die Backlight-LEDs leuchten, und im Streiflicht erkennt man, dass die Pixel-Anordnung sichtbar ist, nur eben in „alle Pixel an“-Stellung. Das ist kein toter Controller, das ist ein lebender Controller mit zu hohem internem LCD-Bias.

Im m-firmware-Source liegt direkt ein Clones-Verzeichnis mit Hinweisen zu bekannten Klon-Variationen. Für den verwandten LCR-T5 steht dort genau dieser Workaround: bei manchen ST7565R-Panels ist der per Software gesetzte Bias zu hoch, der typische Default-Befehl CMD_V0_RATIO | FLAG_RATIO_65 muss runter auf FLAG_RATIO_55 oder sogar FLAG_RATIO_45. Geändert wird das in ST7565R.c in der Funktion LCD_Init():

/* set contrast: resistor ratio 5.5 */
LCD_Cmd(CMD_V0_RATIO | FLAG_RATIO_55);    /* statt FLAG_RATIO_65 */

Mit dieser einen Zeile ändert sich alles. Neuer Build, flashen, und siehe da, plötzlich sind tatsächlich Pixel sichtbar.

Display-Tuning: Orientierung, Kontrast und der abgeschnittene Cursor

„Pixel sichtbar“ heißt noch lange nicht „lesbar“. Was da auf dem Display erscheint, sind erstmal Hieroglyphen kopfüber, spiegelverkehrt, mit komischen Pixel-Mustern an Stellen, an denen eigentlich Zeichen stehen sollten. Das ist die klassische ST7565R-Inbetriebnahme: drei Defines steuern Orientierung und Kontrast, alle drei muss man am eigenen Panel kalibrieren.

//#define LCD_FLIP_X                /* horizontale Spiegelung */
//#define LCD_FLIP_Y                /* vertikale Spiegelung   */
#define LCD_CONTRAST     22         /* 0..63, 22 passt hier   */

Bei meinem Panel sind beide FLIP-Defines aus, was unintuitiv klingt aber stimmt. Der Kontrast landet final bei 22. Nach ein paar Build-Iterationen sieht das so aus:

Display mit korrektem Kontrast aber rechts abgeschnittenem Cursor-Symbol durch LCD_OFFSET_X
Zwischenstand: Orientierung und Kontrast passen, „1-||-3 .15pF“ gut lesbar. Aber das blinkende Cursor-Symbol rechts ist abgeschnitten, weil LCD_OFFSET_X den gesamten Inhalt um vier Pixel nach rechts schiebt.

Klassischer Fall: bestimmte ST7565R-Panel-Varianten brauchen einen X-Offset von vier Pixel, weil deren sichtbarer Bereich rechts beginnt. Andere wiederum nicht. Das Define LCD_OFFSET_X erzwingt genau diese Verschiebung. Mein Panel will sie nicht, also raus damit:

//#define LCD_OFFSET_X            /* deaktiviert: kein +4 Pixel Shift */
Display korrekt zentriert nach Deaktivierung von LCD_OFFSET_X
Nach dem Auskommentieren sitzt der Bildinhalt zentriert, der Cursor ist vollständig sichtbar. Erste komplette Messung lesbar: ein simpler Widerstand.

An dieser Stelle wäre die Geschichte normalerweise vorbei. Display tut, Buttons drücken, Messen, fertig. Wäre da nicht das Problem mit dem Einschalten.

Das Power-Latch-Drama

Der T4-Plus hat einen blauen Druckknopf. Den drückt man, der Tester bootet, misst, schaltet sich nach Inaktivität von alleine wieder ab. So weit der Plan. Mein frisch geflashtes Board allerdings macht etwas Ärgerliches: Knopf drücken, Display bootet kurz an, Knopf loslassen, sofort wieder aus. Das ist kein „Auto-Power-Off nach Timeout“, das ist „MCU verliert beim Loslassen die Stromversorgung“. Klassischer Power-Latch-Bug.

Erster Reflex: irgendein POWER_CTRL-Pin in der Config ist falsch. Die m-firmware hat einen Define für den Pin, an dem der MCU nach dem Boot ein HIGH-Signal anlegen muss, um sich selbst am Leben zu halten. Auf den meisten Boards heißt der Pin PD6 oder ähnlich. Also durchprobiert: PD6, PD7, PD4, PD3, alles was an freien Pins noch übrig ist. Kein Erfolg. Egal welcher Pin, sobald der Finger den Button verlässt, ist Schluss.

Zeit für die Multimeter-Tour über die Latch-Schaltung. Auf der Platine sitzen drei Transistoren um den Power-Knopf herum: Q1 ist ein S9015 (PNP), schaltet die 9V auf die Versorgung. Q2 und Q3 sind beide S9014 (NPN) und treiben gemeinsam mit dem Button-Kontakt die Basis von Q1. Durchgangsmessungen ergeben:

Pin 29 (/RESET) --- 27 kOhm --- Basis Q3
Basis Q3 --- Button-Kontakt --- GND
Kollektor Q3 --- Basis Q1 (über Pull-up)
Q1 schaltet 9V auf den 5V-Regler

Das ist also gar kein „Firmware schaltet POWER_CTRL HIGH“-Design. Die Latch-Schaltung ist passiv über die /RESET-Leitung des MCU aufgebaut. Solange der ATmega läuft, liegt /RESET auf HIGH (intern hochgehalten), und über den 27k zur Basis von Q3 bleibt der Transistor durchgesteuert, Q1 hält die Versorgung an. Drückt man den Button, zieht der Q3-Basis kurz auf GND-Potenzial Richtung positiv und triggert das Hochfahren über einen leicht anderen Pfad. Aber sobald der Boot durch ist und der MCU /RESET HIGH hält, läuft das Ganze von alleine.

Heißt: an meinem Board sollte das auch ohne firmware-seitiges POWER_CTRL funktionieren. Tut es aber nicht. Spannungsmessung unter Strom zeigt: Q3-Basis bleibt dauerhaft bei 0V, der Transistor schaltet nie sauber durch. Aber warum, wenn doch /RESET nach erfolgreichem Boot eigentlich HIGH sein müsste?

An dieser Stelle saß ich gefühlte zwei Stunden an dem Tisch und habe mit dem Multimeter zwischen MCU-Pinout, Transistor-Basen und 9V-Schiene hin- und hergemessen. Mein Verdacht war zwischendurch ein toter Transistor, ein kalter Lötpunkt, ein verkokelter Trace unter dem schwarzen Lötstopplack. Alles falsch.

Der Aha-Moment im Makefile eines fremden Forks

Irgendwann habe ich aus Verzweiflung angefangen, fremde Firmware-Forks für genau dieses Board zu lesen. Es gibt eine zweite quelloffene Linie neben der m-firmware: die k-firmware (das „k“ steht für Karl-Heinz Kübbeler, den Original-Autor), und davon wiederum diverse Forks. Einer davon ist Palingenesis‘ Fork mit einem dedizierten T4-v2-Build-Profil. Ich öffne dessen Makefile, und der Blick fällt auf eine einzelne Zeile:

OP_MHZ = 8

Acht. Megahertz. Genau die Zahl, die ich Stunden vorher auf dem Quartz gelesen und in mein Notizfeld geschrieben hatte. Genau die Zahl, deren Bedeutung ich nicht richtig zu Ende gedacht hatte. Mein Build der m-firmware lief mit FREQ = 16, weil ich davon ausgegangen war, dass das T4-Plus-Board immer 16 MHz Quartz hat. Tut es eben nicht. Beim zweiten Exemplar ist ein 8-MHz-Quartz drauf.

Was das praktisch bedeutet: wenn die Firmware glaubt, sie laufe auf 16 MHz, aber tatsächlich nur 8 MHz Takt verfügbar sind, läuft jede Operation effektiv mit halber Geschwindigkeit. Jedes Timer-Tick, jede Schleife, jede ADC-Konversion dauert doppelt so lang. Im Falle der Initialisierungssequenz heißt das: bis der MCU im Bootcode an der Stelle ist, an der POWER_CTRL HIGH gesetzt wird (oder bis /RESET stabil HIGH ausgegeben wird), ist das Zeitfenster, in dem der Button noch gedrückt ist, schon längst vorbei. Der Latch greift nicht, der Tester schaltet sich beim Loslassen ab.

Drei Buchstaben im Makefile geändert:

FREQ = 8

Neu kompiliert, geflasht. Knopf gedrückt. Display kommt. Knopf losgelassen. Display bleibt an. Der Tester hält sich selbst am Leben. Zwei Stunden Diagnose-Drama wegen einer einzigen Zahl im Makefile, die mit der eigentlichen Hardware nichts zu tun hatte, außer dass die Hardware halt ein anderer Quartz war als gedacht. Genau eine dieser Stellen, an denen man kurz aufsteht und sich was zu trinken holt.

Self-Adjustment und Werte ins Flash schreiben

Nach dem Power-Latch-Fix ist der Tester quasi nutzbar, aber die m-firmware meldet beim Boot „Checksum failure“ für die Adjustment-Werte im Flash. Das ist erwartbar, weil dort ja noch nie eigene Kalibrierwerte gespeichert wurden. Heilmittel: das Service-Menü aufrufen, Self-Adjustment durchlaufen, Werte abspeichern.

Voraussetzung ist, dass UI_SHORT_CIRCUIT_MENU in config.h aktiviert ist. Damit kommt man ins Menü, indem man beim Start alle drei Probes (1, 2, 3) miteinander kurzschließt. Der Tester erkennt das und blendet das Service-Menü ein:

Service-Menü des T4-Plus v2 via 3-Probe-Kurzschluss erreicht
Service-Menü via 3-Probe-Kurzschluss: PWM, IR detector, Opto Coupler, Test, Adjustment, Contrast, Save. Genau das, was man für eine saubere Inbetriebnahme braucht.

Erster Punkt ist „Test“, ein Selbsttest mit allen drei Probes. Anschließend „Adjustment“, da braucht man dann einen 1 µF-Kondensator zwischen Probe 1 und Probe 3. Der Tester misst seinen eigenen internen Ri-Wert, die Eingangskapazität, eine Referenzspannung. Am Ende „Save“, und die Werte landen via DATA_FLASH als Block im Programmspeicher (Self-Programming). Beim TC1 war das genauso, der einzige Unterschied: dort kommen die Daten in den 1 KB EEPROM. Beim T4-Plus reicht der Flash-Bereich und ich nutze DATA_FLASH stattdessen, weil das Self-Programming auf dem ATmega328P stabiler läuft als die EEPROM-Erase-Write-Sequenz.

Self-Adjustment-Ergebnis mit Ri-, Ri+, C0, R0, Vref, Vcc, AComp Werten
Self-Adjustment-Ergebnis: Ri- 20.8 Ω, Ri+ 23.4 Ω, C0 36 pF, R0 0.31 Ω, Vref 1097 mV, Vcc 5130 mV, AComp 0 mV. Werte ins Flash gespeichert, Checksum-Fehler beim nächsten Boot weg.

Diese Werte unterscheiden sich übrigens leicht von Board zu Board. Die Ri-Werte hängen an den konkreten Innenwiderständen der MCU-Ausgangsstufen, C0 und R0 an den Probe-Leitungslängen, Vref am internen Bandgap. Wer die Hex eines anderen Geräts mit dessen Flash-Region 1:1 auf sein eigenes flasht, übernimmt also fremde Kalibrierdaten, die zu falscher Mess-Anzeige führen können. Lieber einmal selbst kalibrieren.

3D-gedrucktes Gehäuse statt nackte Platine

Das Original-T4-Plus kommt ohne Gehäuse, nur die nackte Platine. Für den TC1 gab es ja damals zumindest noch eine billige Plastik-Halbschale, hier ist nicht mal das dabei. Auf Makerworld liegt ein passendes Modell von „LeoNerd“ mit der ID 1891431, „Case for LCR-T4 Component Tester“. Drei Teile: Unterschale, Frontblende mit ZIF-Sockel-Ausschnitt und Display-Fenster, Batterie-Klappe.

3D-gedruckte PETG-Gehäuseteile direkt von der Druckplatte für den LCR-T4-Plus
PETG, 0.2 mm Schichthöhe, fertig auf der Druckplatte. Drei Teile, kein Support nötig.

Ich habe das auf meinem Bambu Lab X1 Carbon in PETG ausgedruckt. PLA ginge auch, aber PETG ist hier ein bisschen weniger spröde, der Tester wird ja immer wieder mal in die Hand genommen. Maße passen direkt, das PCB sitzt sauber zwischen den Stegen, das Display fluchtet, der Button schaut zentrisch durch die Öffnung. Den 9V-Block fixiert die Klappe von hinten.

Finaler Funktionstest im Gehäuse

Fertig zusammengebauter T4-Plus v2 im 3D-Gehäuse mit Boot-Banner Component Tester v1.56m
Boot-Banner „Component Tester v1.56m“ im 3D-gedruckten PETG-Gehäuse. Knopf drücken, Display bleibt an, alles, was es braucht.
Boot-Screen mit korrekter Batteriespannungs-Anzeige Bat 9.61V ok
„Bat 9.61V ok / Probing…“, der BAT_DIVIDER mit 10k zu 3.3k stimmt und eine frische 9V-Batterie wird korrekt erkannt.

Ein paar Testmessungen mit Bauteilen aus der Schublade: 10k-Widerstände, ein paar 100 nF Kondensatoren, ein BC547 und ein paar LEDs. Alle Werte plausibel, BC547 wird mit korrekter Pin-Zuordnung als NPN-Transistor erkannt, hFE in der erwarteten Größenordnung. Damit ist das Gerät einsatzbereit.

Die finale Konfiguration zum Mitnehmen

Damit andere mit dem gleichen 91make-Klon nicht die gleichen drei Tage verbrennen müssen, hier alle Änderungen relativ zur unveränderten m-firmware 1.56m an einer Stelle gesammelt.

Makefile (Auszug, nur die geänderten Zeilen):

MCU    = atmega328       # NICHT atmega328p, sonst greift der Include-Guard nicht
FREQ   = 8               # 8 MHz Quartz auf der v2-Variante, nicht 16
PARTNO = m328p           # avrdude-Part bleibt m328p

config.h (gemeinsame Optionen, aktive Defines):

#define HW_REF25                   /* interne 2.5V-Bandgap-Referenz */
#define UI_AUTOHOLD                /* nach Messung auf Tastendruck warten */
#define UI_SHORT_CIRCUIT_MENU      /* Service-Menue via 3-Probe-Short */
#define POWER_OFF_TIMEOUT 60       /* Auto-Off nach 60 Sekunden Idle */
#define BAT_DIVIDER                /* externer 10k/3.3k-Teiler */
#define BAT_R1     10000
#define BAT_R2     3300
#define BAT_WEAK   7400            /* 7.4V Warnschwelle */
#define BAT_LOW    6400            /* 6.4V Abschaltschwelle */
#define DATA_FLASH                 /* Kalibrierdaten ins Programm-Flash */

config_328.h (ST7565R-Section, alles relevante an einem Stueck):

#define LCD_ST7565R
#define LCD_GRAPHIC
#define LCD_SPI
#define LCD_PORT     PORTD
#define LCD_DDR      DDRD
#define LCD_RESET    PD0
#define LCD_CS       PD5
#define LCD_A0       PD1
#define LCD_SCL      PD2
#define LCD_SI       PD3
#define LCD_DOTS_X   128
#define LCD_DOTS_Y   64
//#define LCD_OFFSET_X            /* AUS, sonst +4 Pixel Verschiebung */
//#define LCD_FLIP_X              /* AUS */
//#define LCD_FLIP_Y              /* AUS */
#define LCD_START_Y  0
#define LCD_CONTRAST 22
#define FONT_8X8_VF
#define SYMBOLS_24X24_VFP
#define SPI_BITBANG

ST7565R.c (genau eine Zeile in LCD_Init()):

/* set contrast: resistor ratio 5.5 */
LCD_Cmd(CMD_V0_RATIO | FLAG_RATIO_55);     /* war FLAG_RATIO_65 */

Flash-Befehl, identisch zum TC1 (nur die Hex-Datei ist eine andere):

avrdude -c avrisp -p m328p -P /dev/ttyACM0 -b 19200 -B 32 
        -U flash:w:ComponentTester.hex:i

Was ich aus dem Nachmittag mitnehme

Vier Punkte, die ich beim nächsten Klon-Tester sofort prüfen werde, statt wieder Stunden in der Diagnose zu verbringen:

  • Quartz mit der Lupe lesen bevor man die Frequenz im Makefile setzt. Identische PCB-Bezeichnung garantiert nicht den gleichen Takt. Bei diesem T4-Plus ist es 8 MHz, bei meinem ersten waren es 16 MHz. Zwei Boards, derselbe Aufdruck, verschiedene Bestückung.
  • ST7565R komplett schwarz nach erstem Flash ist fast immer ein zu hoher Bias und kein toter Controller. Erst FLAG_RATIO_65 auf FLAG_RATIO_55 oder FLAG_RATIO_45 ändern, dann weiter denken.
  • Silkscreen-Labels glauben, aber verifizieren. Auf dem v2-Board sind mis und mosi physisch vertauscht. Einmal Durchgangsprüfung gegen den MCU-Pin spart eine Stunde Frustration.
  • Vermeintliche Hardware-Fehler sind manchmal Build-Parameter. Ein Tester, der sich beim Loslassen ausschaltet, sieht aus wie eine kaputte Latch-Schaltung. War aber in Wirklichkeit nur die falsche CPU-Frequenz im Makefile, was den Boot-Code langsamer laufen ließ, als das mechanische Button-Zeitfenster es zuließ.

Repo und Quellen

Wie beim TC1 habe ich auch hier ein kleines Repo mit der fertigen Hex, den Config-Patches und einer README mit Schritt-für-Schritt-Anleitung gepackt: github.com/Kernel-Error/t4plus-v2-firmware-update. Lizenz folgt der m-firmware (EUPL v1.2), die Config-Patches sind als unified diff gegen die unveränderte 1.56m beigelegt.

Quellen, ohne die das nicht funktioniert hätte:

Siehe auch: TC1 Multifunction Tester mit Open-Source-Firmware (der Vorgänger-Beitrag, gleiche Firmware-Familie, mehr Drama bei der STC-Variante), Multifunktionstester für Elektronikbauteile (mein erster Eindruck von dieser Tester-Klasse 2019), xum1541-Firmware-Bug gefunden und gefixt (anderes Mikrocontroller-Firmware-Drama), OSSC Firmware-Update 1.21 (Firmware-Update an Open-Source-Hardware), Preciva 992D+ Lötstation (für alle die nach diesem Beitrag selber löten wollen).

Fragen, eigene T4-Plus-Klon-Varianten oder noch krummere Silkscreen-Vertauschungen gesehen? Gerne über die fragen-Seite oder als Issue im Repo.

ts3level: TeamSpeak-Identity-Level auf der GPU rechnen, mit automatischem .ini-Patch

Beitragsbild zu ts3level: Eine TeamSpeak-Identity-INI-Datei wird per GPU von Security-Level 45 auf 55 hochgerechnet und automatisch gepatcht.

Wer eine TeamSpeak-3-Identity mit einem höheren Security Level haben möchte als das, was der offizielle Client innerhalb eines Menschenlebens rausrechnet, kommt um GPU-Hilfe nicht herum. Es gibt da seit Jahren eine Handvoll Drittanbieter-Tools. Was bisher fehlte, war der unspektakuläre letzte Schritt: das gefundene Ergebnis wieder in die .ini zu schreiben. Genau diese Lücke schließt ts3level, ein neues Open-Source-Tool, an dem ich die letzten Wochen gesessen habe.

ts3level GUI Hauptfenster mit aktivem Hash-Run, Identity-Details links, GPU-Stats und Auslastungsgraph rechts
Das Hauptfenster während eines laufenden Hash-Runs. Links die geparsten Identity-Details, rechts Live-Progress, Hashrate, ETA und ein kleiner Cairo-Graph mit 60 Sekunden GPU-Auslastung.

Das Repo liegt auf GitHub unter Kernel-Error/ts-identities-security-level (MIT-Lizenz). Release v0.1.0 inklusive prebuilt Tarball für x86_64 Linux mit glibc ≥ 2.39 gibt’s hier. Der Tarball ist rund 2 MB groß, mehr braucht es nicht.

Das Problem: Single-Thread-CPU vs. exponentielles Wachstum

Das „Security Level“ einer TS3-Identity ist nichts anderes als ein Proof-of-Work. Man variiert einen uint64-Counter und hashed solange SHA1(pubkey_b64 || decimal(counter)) bis vorne genug Null-Bits stehen. Pro Level verdoppelt sich der durchschnittliche Aufwand. Der offizielle TS3-Client rechnet das in einem einzelnen CPU-Thread. Ab ungefähr Level 50 wird das schlicht unbrauchbar.

Es gibt seit Jahren GPU-beschleunigte Drittanbieter-Hasher. landave/TSIdentityTool ist die CPU-Referenz in C, mittlerweile dormant. landave/TeamSpeakHasher bringt das auf OpenCL, der CUDA-Fork von thissepic ist aktuell das aktivste Tool und schafft auf einer RTX 4070 Ti rund 20 GH/s. bratkartoffel/ts3idtools macht das in C mit pthreads auf der CPU.

Allen gemeinsam ist die UX-Lücke: keines schreibt das Ergebnis zurück. Der typische Workflow ist Pubkey aus der .ini extrahieren, Hasher füttern, Counter aufschreiben, Counter manuell in die .ini patchen, Datei re-importieren. landave begründet das bewusst mit Privatekey-Schutz, der Hasher soll den Private Key gar nicht erst zu sehen kriegen. Eine sehr saubere Trennung, aber für den Endanwender eine ziemlich friktionsreiche Angelegenheit.

Genau dort setzt ts3level an. Man zeigt auf seine .ini, der Rest passiert automatisch. Atomarer Write, einmaliges .bak, fertig. Drumherum eine GTK-GUI, NVML-Telemetrie, Übersetzungen auf en/de/es/fr, eine ehrliche ETA-Anzeige und ein Identity-Details-Panel, damit man nicht jedes Mal die Datei von Hand parsen muss.

Wie funktioniert „Security Level“ überhaupt?

Die Kernformel ist erfrischend einfach:

level = leading_zero_bits( SHA1( pubkey_b64 || decimal(counter) ) )

pubkey_b64 ist die base64 der public-key-only ASN.1 DER eines ECDSA-Keys auf der Kurve secp256r1 (NIST P-256). Es ist genau der String, den der TS3-Client im Identity-Dialog unter „Public Key“ anzeigt. decimal(counter) ist die ganz normale Dezimaldarstellung des uint64 ohne Padding, also "42" und nicht "0042". Konkatenation passiert auf ASCII-Stringebene, nicht auf irgendwelchen rohen Bytes. Klingt banal, ist aber tatsächlich die häufigste Fehlerquelle bei Re-Implementierungen.

Das Zählen der führenden Null-Bits läuft byte-0-zuerst und innerhalb jedes Bytes von LSB nach MSB. Genau die hashcat-Konvention. In Python wäre das:

zero_bytes = 0
while zero_bytes < 20 and hash[zero_bytes] == 0:
    zero_bytes += 1
zero_bits = 0
if zero_bytes < 20:
    b = hash[zero_bytes]
    while not (b & 1):
        zero_bits += 1
        b >>= 1
level = 8 * zero_bytes + zero_bits

Die Wahrscheinlichkeit dass ein einzelner Hash mindestens Level L erreicht ist 1 / 2^L. Im Mittel braucht es also 2^L Versuche pro angestrebtes Level. Auf meiner Testkarte, einer RTX 4060 Ti bei rund 2,4 GH/s, sieht das so aus:

Ziel-LevelMittlere Wartezeit
40~7 Minuten
45~4 Stunden
50~5 Tage
55~5 Monate
60~14 Jahre

Diese Werte sind statistische Mittelwerte. Die echte Wartezeit ist exponentialverteilt, man kann also mit Faktor 2 bis 3 schneller oder langsamer Glück haben. Genau deshalb zeigt das Tool die ETA auch als das was sie ist (ein Mittelwert), und nicht als seriös wirkenden Countdown.

Was im .ini-File steckt

Eine exportierte TS3-Identity sieht ungefähr so aus:

[Identity]
id=Standard
identity="<counter>V<base64_obfuscated_blob>"
nickname=Kernel-Error
phonetic_nickname=

Der Teil hinter dem V ist doppelt base64-codiert, mit einer XOR-Schicht dazwischen. Das hat landave per Black-Box-Analyse aus dem Client-Verhalten rekonstruiert. Schematisch:

data = base64_decode(blob_b64)        # outer base64
hash_part = data[20:]                  # everything past the first 20 bytes
sha = sha1(hash_part)
data[0:20] ^= sha                      # undo SHA-1 mask
data[0:min(100, len)] ^= TSKEY         # undo static-key XOR
inner_ascii = data                     # ASCII-base64 of the ASN.1 DER
asn1_der = base64_decode(inner_ascii)  # inner base64

Der amüsante Stolperstein dabei: TSKEY ist im C-Original definiert als const char *TSKEY = "b9dfaa7bee...";. Das sind nicht die 64 Hex-Bytes, sondern die 128 ASCII-Zeichen der Hex-Darstellung selbst. Eine Stunde Debugging, bis ich gemerkt habe woran es liegt. Ist halt einer dieser Punkte, bei denen die Spec ohne Quelltext nicht reichen würde.

Im inneren DER liegt der libtomcrypt-formatierte Keypair:

SEQUENCE {
  BIT STRING       flags     -- 1 bit; 0 = public-only, 1 = with private key
  SHORT INTEGER    keysize   -- 32 for P-256
  INTEGER          x
  INTEGER          y
  [INTEGER         k]        -- only when flags = 1
}

Für den Hash-Input brauchen wir nur die public-only Variante dieses DER-Blobs: gleiche Struktur, flags=0, X, Y, und der private Skalar k fällt einfach raus. Der Hasher bekommt damit nie den privaten Schlüssel zu sehen. Genau diese Trennung, die landave aus gutem Grund eingeführt hat, bleibt erhalten. Das Auto-Patchen der .ini passiert in einem separaten Modul, das den Counter zurückschreibt, ohne den Private Key überhaupt anzufassen.

Warum eine GPU

SHA-1 ist ein dankbarer Kandidat für massiv-paralleles Hashing. Es gibt keine Datenabhängigkeiten zwischen verschiedenen Counter-Werten, jede der zigtausend GPU-Threads kann einen anderen Counter testen und das Ergebnis unabhängig vergleichen. Ein moderner Desktop-Kern auf der CPU kommt vielleicht in den niedrigen dreistelligen MH/s-Bereich für SHA-1, eine RTX 4060 Ti im Tool rund 2,4 GH/s. Das reicht um aus Level 50 statt „mehreren Jahren“ so etwas wie „mehreren Tagen“ zu machen, also einen Bereich, in dem ein Run überhaupt erst Sinn ergibt.

Das Tool macht nichts Heldenhaftes mit dem Kernel: simples SHA-1 in CUDA C, ein Counter pro Thread, atomarer Compare-and-Swap auf der „bester gefundener Level“-Variable im global memory. Hochoptimierte Hasher wie der von thissepic kommen mit Midstate-Precompute und LOP3.LUT-Tricks auf ungefähr das fünffache. Dieser Teil steht auf der Roadmap, ist aber für den ersten Release bewusst nicht angegangen worden. Der eigentliche Mehrwert von ts3level ist UX und Auto-Patch, nicht der letzte ausgequetschte GH/s.

Was das Tool macht: zwei Binaries, ein Source-Tree

Aus dem Workspace fallen zwei Binaries:

  • ts3level: headless CLI mit Progress-Spinner, gut für Server, tmux, SSH-Sessions
  • ts3level-gui: GTK4 + libadwaita Desktop-Anwendung, übersetzt in en/de/es/fr

Konkrete Use-Cases, die im Auge waren beim Entwurf:

  • Die eigene TS-Identity von einem aktuellen Level (z. B. 45) auf ein Wunsch-Level (z. B. 55) hochrechnen, ohne sich mit base64-Blobs und Counter-Werten herumzuschlagen.
  • Headless auf einem Server mit einer NVIDIA-Karte über Nacht laufen lassen, morgens fertige .ini aus dem Backup-Verzeichnis ziehen.
  • Endless-Mode: das Tool läuft permanent, jedes Mal wenn ein höheres Level gefunden wird, wird die Datei sofort aktualisiert. Wer kein konkretes Ziel hat und einfach gucken will, was an einem Wochenende rauskommt, fährt damit ganz gut.
  • Identity-Details ablesen, ohne die .ini per Hand zu parsen: Nickname, Fingerprint (also die TS3-„Unique ID“), Public Key, Current Level, Current Counter.

Beim Start auf eine frisch generierte Demo-Identity sieht der CLI-Output so aus:

Using device: [CUDA:0] NVIDIA GeForce RTX 4060 Ti (cc 8.9, 34 SMs, 15.6 GiB)

Nickname: DemoUser
Local ID: DemoStandard
Fingerprint: 3cltecu4AFn+OK7Lx3Ish6wZN+Y=
Current level: 25  (counter: 7131210)

note: target 1 <= 25 (current level): raising to 26
Target: 26

Und während der Run läuft sieht die Statuszeile so aus:

⠹ level: 50  counter: 1964603892897982  2.45 GH/s  ETA→+1: 0.4s  ETA→target: 10.3d  GPU: 100% 76°C 165W  (10s)

In der GUI ist genau dieselbe Information da, plus ein 60-Sekunden-Ringbuffer-Graph der GPU-Auslastung. Praktisch wenn man parallel zockt und sehen will, ob der Hash-Run noch genug Luft hat oder die Karte sich gegen das Spiel durchsetzen muss.

Architektur (kurz)

Komplett Rust, fünf Crates in einem Workspace:

crates/ts3level-core/    parser, obfuscation, pubkey extraction, CPU SHA-1, atomic writer
crates/ts3level-engine/  HashEngine trait, preflight, driver loop, NVML telemetry
crates/ts3level-cuda/    SHA-1 kernel in CUDA C, bound via cudarc
crates/ts3level-cli/     clap + indicatif + gettext
crates/ts3level-gui/     gtk4-rs + libadwaita + Cairo graph

Beim Build ruft build.rs einmal nvcc --fatbin auf und erzeugt eine Fatbin mit echtem SASS für sm_70, sm_75, sm_80, sm_86, sm_89, sm_90, plus PTX-Fallback für künftige Architekturen. Diese Fatbin wird per include_bytes! direkt in das Rust-Binary einkompiliert. Der Endanwender braucht damit nur den NVIDIA-Treiber, kein CUDA-Toolkit. libcuda.so.1 und libnvidia-ml.so.1 werden zur Laufzeit per dlopen gezogen, das funktioniert ab Treiber 525 ohne Klimmzüge.

Das Zurückschreiben in die .ini macht das Tool über flock(LOCK_EX) auf die Datei, einmaliges .bak wenn keines existiert, neue Bytes in eine tempfile::NamedTempFile im selben Verzeichnis, fsync, anschließend rename(2) drüber. POSIX-atomar. Bei Stromausfall mittendrin gibt es entweder die alte oder die neue Datei, nie ein halbgares Gemisch.

Vor jedem Run prüft ein Preflight neun Bedingungen: libcuda ladbar, CUDA-Devices vorhanden, Zugriffsrechte auf /dev/nvidia*, .ini lesbar, schreibbar, Parent-Dir schreibbar, Datei strukturell gültig, kein konkurrierender Lock, genug Platz für eine .bak. Fehler werden als strukturierte Codes ausgegeben, dokumentiert in der README. Backtraces gibt es nicht, weil sie für Endanwender nur Lärm sind.

Tests stehen bei 68 verteilt über alle Crates: 40 in ts3level-core (Parser, Obfuscation, Pubkey, Level-Berechnung, Writer, Sign-Prefix-Edge-Cases), 18 in ts3level-engine (Preflight, Driver, ETA, GpuStats), 3 in ts3level-cuda (eine Million Counter Parity GPU vs. CPU, Hashrate-Probe), 7 in ts3level-cli (Smoke-Tests, End-to-End gegen eine echte GPU, i18n).

Validierung gegen den echten TS3-Client

Einfacher aber wichtiger Schritt: nachdem das Tool intern korrekt aussah, habe ich eine reale Identity aus dem TS3-Client exportiert und parallel den vom Tool berechneten Fingerprint und das Security Level mit der Anzeige im Client verglichen. Beides byte-identisch. Heisst: die Algorithmus-Spec stimmt, es gibt kein Off-by-One zwischen meiner Implementierung und dem, was ein TS-Server für die Identifikation tatsächlich sieht. Wer den Counter mit ts3level hochrechnet, bekommt eine Identity, die der Client genauso akzeptiert, als hätte er den Aufwand selbst betrieben.

Hardware-Support

Out-of-the-Box im prebuilt Tarball:

GenerationBeispieleCompute Cap
VoltaTitan V, V100sm_70
TuringRTX 2060/70/80, T4sm_75
Ampere DCA100sm_80
Ampere ConsumerRTX 3060/70/80/90sm_86
AdaRTX 4060/70/80/90sm_89
HopperH100sm_90
Blackwell und künftigeRTX 5xxx, GB200sm_100+ via JIT

Pascal (GTX 10-Serie) und älter sind bewusst nicht dabei. Eine Zeile in build.rs ergänzen und neu bauen reicht, dann läuft auch das, nur ist die SHA-1-Performance auf diesen Karten ohnehin nicht konkurrenzfähig.

Installation

Auf Ubuntu, Mint oder Debian sieht das so aus:

sudo apt install nvidia-driver-550 libgtk-4-1 libadwaita-1-0 gettext-base
sudo usermod -aG render $USER   # log out and back in afterwards
curl -LO https://github.com/Kernel-Error/ts-identities-security-level/releases/download/v0.1.0/ts3level-v0.1.0-x86_64-linux.tar.gz
tar -xzf ts3level-v0.1.0-x86_64-linux.tar.gz
cd ts3level-v0.1.0-x86_64-linux
sudo install -m755 bin/ts3level     /usr/local/bin/
sudo install -m755 bin/ts3level-gui /usr/local/bin/
sudo cp -r share/locale/*           /usr/share/locale/

Verifizieren mit ts3level --list-devices. Die volle Installationsanleitung inklusive Rechte-Setup und Troubleshooting steht im Repo unter docs/installation.md.

Bekannte Grenzen und Roadmap

Ehrlich bleiben:

  • 2,4 GH/s auf der 4060 Ti sind nur ungefähr ein Achtel dessen, was thissepic mit hand-optimierten Kernels rausholt. Midstate-Precompute, In-Place-Counter-Inkrement und LOP3.LUT-PTX-Tricks würden das in den 10 GH/s-Bereich heben. Steht auf der Roadmap, aber nicht für den ersten Release.
  • v0.1 ist NVIDIA-only. Das HashEngine-Trait ist allerdings genau dafür gedacht: ein OpenCL-Backend für AMD und Intel wäre ein relativ entspannter Drop-In.
  • Kein Flatpak bisher. Wer auf älteren Distros sitzt, etwa Ubuntu 22.04 mit glibc 2.35, muss aus den Quellen bauen. Lässt sich machen, ist aber nicht das Plug-and-Play das man von einem Tarball erwartet.
  • Pascal und älter brauchen den oben erwähnten Einzeiler in build.rs.

Die volle Roadmap mit Effort/Impact-Einschätzung liegt im Repo unter docs/roadmap.md.

Lizenz, Repo, Disclaimer

MIT-Lizenz, Repo unter github.com/Kernel-Error/ts-identities-security-level. Releases inklusive prebuilt Tarball werden auf der Releases-Seite veröffentlicht.

Dieses Tool ist nicht mit der TeamSpeak Systems GmbH affiliiert, von ihr endorsed oder gesponsort. „TeamSpeak“ ist eine Wortmarke der TeamSpeak Systems GmbH und wird hier ausschließlich beschreibend verwendet.

Das Tool kommuniziert nicht mit TS-Servern, umgeht keine Zugangsbeschränkungen, rechnet ausschließlich offline auf einer Datei, die dem Anwender selbst gehört. Algorithmisch dasselbe was der offizielle Client auch tut, nur eben auf der GPU. §69d UrhG (Beobachtung, Untersuchung und Testen von Software) deckt die Black-Box-Analyse ab, aus der die Algorithmus-Specs in Tools wie landave/TSIdentityTool über Jahre öffentlich entstanden sind. §202c StGB greift nicht: kein Bypass einer Zugangskontrolle, keine fremden Systeme, eigene Daten. Mein Versuch einer rechtliche Einordnung steht im Repo unter docs/legal.md.

Siehe auch: Voltcraft CM 2016 mit GTK4-GUI unter Linux (anderes GTK4-Projekt, andere Hardware), peon-ping Sound-Benachrichtigungen (kleines Rust-Tool fuer den Desktop).

Fragen, Kommentare oder Verbesserungsvorschläge gerne über die fragen-Seite oder als Issue im Repo.

Open Source Scan Converter: Firmware-Update von 1.08a auf 1.21 nachgeholt

Nahaufnahme des OSSC LCD-Displays mit Bootscreen OSSC fw. 1.08a 2014-2023 marqs im roten Acrylgehäuse, daneben die Beschriftung mSD am Gehäuse

Auf dem LCD steht „OSSC fw. 1.08a, 2014-2023 marqs“. Wenn ich ehrlich bin: dieses Banner zeigt mir das Gerät seit Ewigkeiten, ich war einfach nie in der Stimmung das mal aufzuräumen. „Läuft ja“ hat sich da über die Jahre breit gemacht. Seit 1.08a sind inzwischen viele Releases erschienen, der Sprung von 1.10 bis 1.21 hat eine Menge mitgebracht: Lumacode, Shadow-Mask-Presets, ein komplett neu geschriebenes SD-Profil-Handling und ein Stück mehr Display-Kompatibilität. Und ab 1.20 funktioniert sogar das Update selbst endlich vernünftig. Also Zeit, das mal sauber durchzuziehen.

Wer den OSSC nicht kennt: kurz vorweg, warum das Teil bei mir seit Jahren auf dem Tisch liegt und nicht im Karton.

Was ist der OSSC eigentlich?

Der Open Source Scan Converter (OSSC) ist ein FPGA-basierter Line-Multiplier von Markus „marqs“ Hiienkari. Schaltpläne und Firmware liegen komplett offen auf github.com/marqs85/ossc. Auf dem Board sitzt ein Altera Cyclone IV, der analoges Video von Retro-Konsolen und Computern aufnimmt und digital per HDMI wieder ausspuckt. SNES, Mega Drive, N64, PS1/PS2, Saturn, Dreamcast, Amiga, Neo Geo, frühe PCs mit VGA, der C64 über entsprechende Adapter, alles was RGB, Component oder Composite/S-Video raushaut, kommt rein.

Der eigentliche Trick ist nicht das Skalieren an sich, sondern wie der OSSC skaliert. Er arbeitet Zeile für Zeile und benutzt keinen Frame-Buffer. Eine eingehende Scanline wird sofort mehrfach (Line2x, Line3x, Line4x, Line5x) ausgegeben, das war’s. Geräte wie der Framemeister scalen über einen kompletten Frame-Buffer und addieren dadurch im Worst Case einen ganzen Frame Latenz oder mehr. Der OSSC liegt im Bereich weniger Mikrosekunden plus den Pixel-Delay des Displays. Auf einem ordentlichen 1080p- oder 4K-Monitor sieht ein 240p-Konsolensignal damit pixelgenau scharf aus, und das ohne dass sich der Controller anfühlt als hätte er einen halben Sekundenschlaf eingelegt.

Pro Konsole lassen sich Sample-Modi und Custom-Profile abspeichern, dazu kommen optionale Scanline-Simulation und seit den neueren Firmwares Shadow-Mask-Presets, also so eine Art simulierter Lochmasken-Look wie auf einer alten Trinitron-Röhre. Mein Gerät ist eine Hardware-Revision v1.6 mit Audio-Support im roten Acrylgehäuse, also nehme ich die -aud-Variante der Firmware. Mehr dazu gleich beim Flashen.

Top-Down-Ansicht des Open Source Scan Converter mit allen Anschlussbeschriftungen: AV1 OUT HDMI, AV2 IN, AV1-SCART-IN, AV2-YPBR-IN, AV3 IN, OFF-ON-Schalter, 5V DC, BTN0, BTN1, JTAG-Pins und mSD-Slot

Auf der Oberseite lesbar: AV1-SCART-IN für RGB-Signale per SCART, AV2-YPBR-IN für Komponenten-Video oder VGA über einen entsprechenden Adapter, AV3 IN als optionaler Composite/S-Video-Eingang. Daneben der HDMI-Out (DVI-kompatibel, mit Audio auf meiner Revision), zwei Taster BTN0 und BTN1 für Navigation ohne Fernbedienung, die JTAG-Pins für Bastler und der für den Update-Vorgang interessante mSD-Slot.

Wofür ich das Ding eigentlich benutze

Bei mir landet der OSSC immer dann auf dem Tisch, wenn alte Hardware an einen modernen Bildschirm soll und dabei auch noch sauber auf dem PC aufgezeichnet werden muss. HDMI raus aus dem OSSC, in einen USB-HDMI-Grabber rein, fertig ist die Aufnahme. Das geht eben nicht nur für die offensichtliche Schiene 386er oder Spielekonsole. Auch ein alter Videorecorder, eine analoge Kamera die ich für jemanden repariere oder einfach mal eben auslesen muss, eine 8-Bit-Maschine die plötzlich wieder einen Use-Case bekommt, all das geht über denselben Weg ins Bild. Mein guter alter C64 hängt mit ein paar Handgriffen am OSSC und ist sofort auf dem 27-Zoll-Monitor zu sehen, statt umständlich einen kleinen Röhrenmonitor aus dem Schrank zu wuchten.

Seitenansicht des OSSC im roten Acrylgehäuse mit gelbem SCART-Adapter oben auf dem Gerät, angeschlossenes Netzteil und LCD-Display mit Firmware 1.08a

Zwei Beispiele aus meinem YouTube-Kanal, beide via OSSC aufgenommen, damit man sich vorstellen kann was am Ende rauskommt:

Was sich seit 1.08a getan hat

Zwischen meiner alten 1.08a und der aktuellen 1.21 hat marqs in mehreren Releases einiges nachgelegt. Die spannendsten Punkte aus den offiziellen Release-Notes in der Reihenfolge wie sie aufgetaucht sind:

  • 1.10: Erste Lumacode-Unterstützung, HDMI-VRR-Flag, reduzierter Sampling-Jitter in den Optimized-Modes, MSX-Sync-Erkennung gefixt, neue High- und Optimal-Sampling-Raten für Passthru.
  • 1.11: Lumacode auch für NES, 480p/576p im Line3x-Modus, Settings-Export wieder eingebaut, neue Display-Kompatibilitätsoptionen (ADC-/FPGA-PLL-Bandbreite, HPLL2x-Controls).
  • 1.12: Lumacode auf Atari GTIA und VCS erweitert, HDR-Infoframe-Wiederholung gefixt, Default-ADC-PLL-Bandbreite reduziert für mehr Display-Kompatibilität, Full-VSYNC-Bypass für MDA-Karten.
  • 1.20: Volle FAT32/exFAT-Unterstützung für die SD-Karte, neues Profil-Format mit deutlich verbesserter Kompatibilität zwischen Firmware-Versionen, Shadow-Mask- und Lumacode-Presets laden und speichern direkt von SD, OSD-Cursor-Farbe wählbar, DIY-Latency-Tester und Panasonic-Hack wieder verfügbar, alternativer Firmware-Slot im internen Flash, 480p/576p-Pillarbox-Option für Widescreen-Displays ohne Aspect-Ratio-Control.
  • 1.21: Lumacode-Support finalisiert, Profil-Speichern und -Laden über SD gefixt.

Für mich relevant: das neue SD-Profil-Handling und die paar Display-Kompatibilitätsschrauben. Lumacode ist eine andere Geschichte (ein Trick um Composite-only-Konsolen wie NES oder Atari VCS auf Luma-Ebene farbgetrennt einzulesen, dafür brauche ich aber spezielle Lumacode-Kabel und passe heute eher).

Pre-1.20 heißt: dd, nicht copy

Hier ist der Knackpunkt, an dem viele beim Update straucheln. Der Bootloader auf dem OSSC erwartet die Firmware ab Sektor 0 der microSD, also als rohes Disk-Image. Eine FAT32-Partition mit der .bin drin reicht ihm nicht. Erst ab Version 1.20 wurde der Update-Mechanismus umgebaut, danach genügt der simple Datei-Copy in ein /fw/-Verzeichnis auf einer FAT32- oder exFAT-formatierten Karte. Wer wie ich von 1.08a kommt, muss diesen Schritt einmal mit dd machen, und ab dem nächsten Mal ist die SD-Karte dann nur noch ein Datenträger und kein Bootloader-Trick.

Heruntergeladen habe ich ossc_1.21-aud.bin direkt vom v1.21 Release. Die Datei ist 391 KB groß. marqs liefert in den Releases selbst keine Prüfsumme mit, also habe ich nach dem Download lokal eine SHA256 gezogen, damit ich beim Schreiben weiß dass die Datei nicht unterwegs verstümmelt wurde:

sha256sum ossc_1.21-aud.bin
12703269c8a2e9ff94dfc37b9baa3e2865476196739baa551c0a93a174b18965  ossc_1.21-aud.bin

Dann auf die microSD. Eine beliebige Karte tut es, idealerweise eine kleine die man nicht für etwas Wichtiges braucht, das Image überschreibt eh den kompletten ersten Bereich. dd ist gnadenlos, einmal das falsche Device erwischt und die Systemplatte ist Vergangenheit. Also vorher mit lsblk oder dmesg | tail sicher feststellen, welches /dev/sdX die SD-Karte ist. Bei mir hier /dev/sdc, aber das ist auf jedem System ein anderes Ziel.

sudo dd if=ossc_1.21-aud.bin of=/dev/sdc bs=1M conv=fsync status=progress
sudo sync

Kurzer Reminder, weil das hier wirklich wehtut wenn man es verbockt: das of= zeigt auf das ganze Device, nicht auf eine Partition. Also /dev/sdc, nicht /dev/sdc1. Und nicht die Systemplatte erwischen, ich weiß ja nicht ob ich das oft genug schreiben kann.

Am Gerät

Karte raus aus dem Card-Reader, rein in den mSD-Slot am OSSC. Gerät einschalten, mit der Fernbedienung (oder den beiden Tastern am Gehäuse) durch das Menü zu Settings → Fw. update und einmal bestätigen. Der OSSC liest das Image direkt von der microSD, schreibt es ins interne Flash und meldet nach ein paar Sekunden Erfolg. Danach aus, microSD raus, wieder an. Im Bootscreen steht jetzt brav „OSSC fw. 1.21“, und das alte „2014-2023“-Banner ist weg. Kein Drama, keine Fehler, einfach erledigt.

Eine Sache die in den Release-Notes explizit erwähnt wird und auf die man sich einstellen sollte: Profile und Settings sind zwischen Firmware-Versionen nicht garantiert kompatibel. Bei dem Sprung von 1.08a auf 1.21 erst recht nicht. Alles was an Custom-Sample-Phasen, Modi und Per-Console-Tweaks gespeichert war, muss einmal neu konfiguriert werden. Ab 1.20 ist das neue Profil-Format dann deutlich stabiler und überlebt zukünftige Sprünge eher, das war ja Teil des Cleanups in dem Release.

Ab 1.20 wird’s bequem

Das nächste Update bekomme ich jetzt geschenkt. Eine FAT32- oder exFAT-formatierte microSD, ein Ordner /fw/ im Root, die .bin reinkopiert, Karte in den Slot, im Menü das Update starten. Kein dd, kein „war das jetzt das richtige Device“, keine Karte die ich danach erst neu formatieren muss damit andere Geräte sie wieder anfassen. Das einmalige Holperstück dd war also nur weil ich so lange mit der alten Firmware unterwegs war. Wer schon auf 1.20 oder 1.21 ist, kommt nie wieder in die Verlegenheit.

Fazit

Der Aufwand für so ein Firmware-Update ist überschaubar, der Effekt aber spürbar. Ein paar weniger Display-Quirks, ein wirklich brauchbares Profil-Handling und das gute Gefühl dass das Gerät auf dem aktuellen Stand ist, das ein Hobby-Entwickler aus Finnland inzwischen über zehn Jahre pflegt. Open Source Hardware in der Praxis: ein einzelner Mensch, ein offenes Repository, ein FPGA und eine kleine Community von Retro-Enthusiasten die das Ding am Leben halten. Genau die Sorte Projekt, die ich gerne unterstütze, sei es nur indem ich auch mal Bug-Reports oder Erfahrungen rausschicke.

Wer selbst einen OSSC zu Hause hat und noch auf einer Firmware vor 1.20 ist, dem kann ich nur raten: einmal die saure dd-Pille schlucken und auf 1.21 hochziehen. Ab dann ist Updaten so langweilig wie Datei kopieren, und genau so soll es ja sein.

Siehe auch: TC1 Multifunction Tester: Open-Source-Firmware flashen (anderes Gerät, gleiches Open-Source-Spielprinzip), Commodore Floppy Disk Preservation mit xum1541 (für die C64-Diskettenseite des Retro-Stacks) und VC-64 Turbo Tape (1986).

Fragen oder eigene OSSC-Geschichten? Einfach melden.

AI, der Mensch als Flaschenhals und meine Sorgen für die nächsten 15 Jahre

Diesmal kein tiefer Tech-Dive. Ein paar Gedanken, die mich seit Wochen begleiten und die ich gerne aus dem Kopf herausschreibe. Es geht um AI, Large Language Models, die Geschwindigkeit in der das alles passiert, und um meine Sorge, ob wir als Menschheit damit überhaupt klarkommen. Kein AI-Bashing. AI kann zum einen nichts dafür und ist zum anderen sehr hilfreich. Aber die immer schnellere Verbreitung immer besser werdender AI-Systeme hat eben Konsequenzen, und genau die sortiere ich hier für mich.

Mensch zwischen KI-Automatisierung und gesellschaftlicher Unsicherheit über Arbeit, Bildung, Politik und Zukunft.

AI ist nicht böse, die Geschwindigkeit ist es

AI an sich ist nicht böse. Large Language Models sind nicht böse. Aus meiner Sicht ist das einfach der nächste Schritt in der Entwicklung, die wir als Menschheit hinlegen. Beim Rad war es nicht anders, beim Feuer, bei der Dampfmaschine, beim automatischen Webstuhl, bei der Elektrizität in den Häusern. Alles Techniken, die irgendwann neu zur Menschheit gekommen sind und die das Leben und Arbeiten verändert haben.

Der Unterschied zu vielen dieser vorangegangenen Technologien ist die Geschwindigkeit. Pferde und Kutschen gegen das Automobil: inzwischen sind wir in der Autozeit angekommen, alles um uns herum ist darauf ausgelegt, dass das funktioniert. Aber dazwischen lag fast ein ganzes Jahrhundert. Eine Generation konnte ihren Job noch zu Ende machen, ihr Geschäft noch zu Ende führen, manchmal sogar noch an die Kinder übergeben. Der Stellmacher als Beispiel. Bei AI haben wir diese Zeit nicht.

Der Mensch ist inzwischen der Flaschenhals

Bei vielen anderen Techniken war der Mensch bei der Verbreitung zwar auch ein Flaschenhals, aber es gab tausend andere Dinge, die zusätzlich gebremst haben. Bei AI ist inzwischen der Mensch der eigentliche Flaschenhals. Solche Systeme könnten sich fast schon selbst weiterentwickeln, oder Menschen könnten zumindest in Zusammenarbeit mit AI viel schneller neue Systeme auf den Markt bringen. Nur sind die Menschen noch nicht da.

Seit ungefähr 4 bis 5 Jahren ist das ganze AI-Thema immer stärker geworden. Seit zwei Jahren ist es auch in der breiten Öffentlichkeit angekommen und sickert dort immer tiefer ein. Aber selbst die Menschen um uns herum, selbst ich, sind noch nicht so darauf eingestellt, wie es bei dieser Geschwindigkeit eigentlich nötig wäre.

Und das gilt nicht nur für die Allgemeinheit. Selbst Experten, die den AI-Hype-Train voll mitreiten und das zu ihrer Profession gemacht haben, schaffen es nicht, sich jedes neue Modell wirklich anzugucken und auszuprobieren. Einfach weil es so schnell und so viel ist. Die Menschen können es gar nicht mehr greifen.

Sprachen, Code-Massen und der schleichende Kontrollverlust

In der Systemadministration, im DevOps-Bereich oder in der Entwicklung sieht man die Geschwindigkeit an vielen Stellen. Es gibt immer eine neue Sprache, die irgendwelche Vorteile gegenüber einer alten hat oder besser für einen Nischenbereich passt. Leute steigen ein, werden gut darin, entwickeln die Sprache weiter, dann kommt die nächste Iteration. Ob Go, TypeScript, Rust, irgendeine Sprache ist immer gerade die gehypte.

Aber da muss man als Mensch ja erstmal reinkommen. Und das alles ist für Menschen gemacht. Eine AI könnte im Zweifel direkt in etwas deutlich Simpleres schreiben, oder sich eine komplett neue Sprache ausdenken, wenn man sie lassen würde. Eine Sprache, die für die AI selbst optimiert ist, um Dinge umzusetzen. Ich will nicht sagen, dass ich das alles wüsste oder dass das wahr ist, das sind nur meine Gedanken. Aber ich glaube, dass Menschen Sorge haben, an dieser Stelle Kontrolle abzugeben. Wenn eine AI ihre eigene Programmiersprache nutzt und in dieser Sprache Software entwickelt, dann versteht das am Ende keiner mehr. Und da frage ich mich auch: muss das denn überhaupt noch jemand verstehen?

Wenn ich mir anschaue, was in meinem Berufsumfeld aktuell passiert, würde ich fast nein sagen. Riesige Softwareprojekte und komplexe Themen werden vollständig durch AI generiert. Fast keiner fängt mehr wirklich damit an, echten Code von Hand zu schreiben. Was bei einem Pull-Request herausfällt, sind Massen an Code, die kein Mensch mehr wirklich liest. Wenn eine AI einen Tag lang Software entwickelt und das Ganze in einen Commit packt: welcher Mensch setzt sich dann hin und liest diese 100 Seiten Code einmal gegen, um zu prüfen ob er gut oder schlecht ist? Wer soll das bewerten?

Das kann sowieso schon keiner mehr. An der Stelle lässt man verschiedene Tools, und auch wieder eine AI, bewerten ob das gut oder schlecht ist. Dann mergt man nach Empfehlung. Sonst baut die AI die Software in einem Tag und ein Team von Entwicklern muss diesen Code eine Woche prüfen. Das ist Quatsch.

Wenn man das weiterspinnt, kommt man irgendwann an den Punkt, wo in der Softwareentwicklung und in der Systemadministration ganz viel echte Kontrolle über IT-Systeme verloren geht. Die wird am Ende an die AI abgegeben. Klar kann man die AI das schön dokumentieren lassen, sich Anleitungen schreiben lassen, theoretisch kann sich am Ende auch wieder ein Mensch einarbeiten. Aber wenn wir ehrlich sind: die Leute wollen Geld verdienen. Das passiert an dieser Stelle nicht.

Mythos, Open Source und der neue Patch-Druck

Vor allem in Richtung IT-Security ist in den letzten Wochen und Monaten viel durch die Presse gegangen. Anthropic, der Hersteller von Claude Code, soll mit einem Modell namens Mythos arbeiten, das in der Berichterstattung als sehr leistungsfähig beim Code-Audit beschrieben wurde. Wo genau das im Vergleich zu den jeweils aktuellen Modellen anderer Anbieter steht, kann ich nicht seriös einschätzen. Spannender ist sowieso, was so ein System angeblich kann: so schnell und zuverlässig Sicherheitslücken im Code finden, dass es katastrophal wäre, das einfach in die freie Welt rauszulassen. Stattdessen bekommen scheinbar nur sehr ausgewählte Leute und Unternehmen Zugriff darauf, meist US-Unternehmen, um ihre eigenen Dinge und Dienste zu prüfen.

Diese neuen Modelle sind nicht primär für klassisches Pentesting gegen eine Blackbox gedacht. Sie schauen in den Code und finden dort die Lücken, die man ausnutzen kann. Klar, fürs Pentesting kann man AI auch benutzen. Aber die große Angst beim Mythos-Thema ist genau dieser Code-Audit-Modus.

Aus dem ersten Blickwinkel sind damit Open-Source-Projekte besonders gefährdet, weil deren Quellcode offen im Internet steht. Früher wurde gesagt: genau das macht Open Source sicher, weil viele Leute reinschauen und Sicherheitslücken finden, die dann gefixt werden. Im Vergleich zu Closed-Source-Code, etwa bei Microsoft Windows, wo nicht jeder einfach in den Code schauen kann.

Bei Open Source sind in letzter Zeit viele Fixes gekommen. Mozilla hat Fixes gemacht, FreeBSD hat Fixes gemacht, viele Sicherheitslücken wurden geschlossen. Die Software wurde sicherer, fertig. Man kann jetzt sagen: ich halte das nächste, bessere AI-Modell wieder dagegen, und es wird wahrscheinlich wieder etwas finden. Hundertprozentige Sicherheit ist eh schwierig. Aber zumindest ist die Software gerade einen Schritt sicherer geworden.

Auf der anderen Seite setzt das die Systembetreiber unter Druck. Die müssen sich überlegen, wie sie diese schnellen, aufeinanderfolgenden Sicherheitsupdates in ihre Systeme bekommen. Klingt erstmal einfach. Ich sitze am Notebook, das Notebook sagt es gibt Updates, ich sage ja, installiere die Updates, starte das Notebook neu, alles funktioniert, ich bin aktuell. Wer aber eine normale Linux-Distribution mit vielen Zusatzpaketen auf dem Arbeitsplatz hat, sieht, dass mehrfach am Tag Updates kommen können.

Bei einfachen Security-Fixes sollten die Funktionen einer Library, einer Anwendung oder des Betriebssystems eigentlich nicht extrem auf den Kopf gestellt werden. Sie sollten nicht dafür sorgen, dass Abhängigkeiten brechen und plötzlich etwas nicht mehr funktioniert. Das Problem hat man eher, wenn man ganze Versionen wechselt, also auf das nächste Major Release geht.

Trotzdem gibt es Bereiche, in denen man nicht einfach mal einen Patch einspielen kann, weil Patches vorher geprüft und getestet werden müssen. Etwas Geheimes, etwas Staatliches, der Bankensektor, kritische Bereiche, die nicht ausfallen dürfen und bei denen alles zertifiziert sein muss. Das aktuelle Regelwerk steht dem entgegen, dass man im Zweifel drei oder fünf Mal am Tag etwas patchen müsste. Das lässt sich schwer miteinander vereinbaren.

Plötzlich kann jeder einen Cloud-Service starten

Der Patch-Druck ist nur eine Seite. Auf der anderen verändert AI gerade, wer überhaupt Software auf den Markt bringen kann. Plötzlich ist jeder mit einer Kreditkarte und einem Computer in der Lage, eine eigene Software, einen eigenen Service, eine eigene Dienstleistung anzubieten, in einer Cloud seiner Wahl zu hosten, global verteilt nah bei den jeweiligen Kunden. Das kann jetzt wirklich jeder.

Und das, was dabei als Code und Anwendung herausfällt, ist nicht mehr wie in den Anfängen der AI-Modelle. Es wird immer besser und stabiler. Im Grunde kann ein Ein-Mann-CEO-Unternehmen einen kompletten Software-Service aufmachen: automatisiertes Ticketsystem per AI, KI-Hotline, AI-Werbung, AI-Webseite, AI-Marketing, das AI-Produkt läuft vor sich hin.

Der Aufwand, ein solches Produkt überhaupt zu entwickeln und auf den Markt zu bringen, ist extrem gering geworden. In den nächsten zwei oder drei Jahren werden wir mit Sicherheit feststellen, dass der Markt überall auf der Erde mit solchen Programmen und Diensten regelrecht überschwemmt wird. Die werden sich im Preis immer weiter unterbieten. Die Baseline wird irgendwo bei den Kosten des Cloud-Providers liegen, plus dem monatlichen AI-Modell der einen Person dahinter.

Schwieriger sind nur sehr spezielle Nischen in einem bestimmten Markt, etwa eine deutsche Buchhaltungsanwendung. Oder Zertifizierungen bei sicherheitskritischen Themen, ISO 27001 oder BSI C5. Das wird für solche Solo-CEO-Firmen noch einige Jahre schwieriger zu erreichen sein. Aber auch das schützt den Markt nicht für Jahrzehnte. Das ist ein Deckel für die nächsten fünf bis zehn Jahre, und auch der wird kräftig anfangen zu bröckeln.

Verlieren wir die Übung im logischen Denken?

AI sorgt vielleicht auch dafür, dass wir Übung und Routine darin verlieren, logisch Probleme zu lösen. Man hört das nicht immer direkt, aber ich glaube, da ist etwas dran. In manchen Ländern liegt schon etwas mehr Augenmerk darauf, dass Schüler und Lehrkräfte keine AI benutzen, wenn es um Schulstoff oder Aufgaben geht. Einfach damit die Leute in dieser Fähigkeit drinbleiben.

Ob das besser oder schlechter ist als der Ansatz hier, wo AI zum Teil schon mitbenutzt wird, weiß ich nicht. Man muss sich mit der Technik auseinandersetzen, man muss verstehen wie sie funktioniert, um hineinzukommen. Aber wahrscheinlich ist auch das in fünf Jahren nicht mehr nötig. Die Modelle sind dann so weit, dass man keinerlei Vorahnung mehr braucht. Man geht zum Handy, oder was wir bis dahin als Gerät haben, sagt: hier ist mein Problem. Und das Ding baut die Lösung.

Brauchen wir noch Code-Repositories?

Im Moment haben wir noch Code-Repositories, Pipelines zum Deployen, Linter, Sicherheitsscanner, SonarQube oder Ähnliches. Wir machen Commits, schreiben Kommentare in den Quellcode, legen das alles dort ab. Wir müssen zu alten Releases zurückrollen können. Wir machen Releases, wir haben Software-Lifetime.

Aber wer sagt, dass das so bleibt? Warum kann man nicht einfach jedes Mal, wenn man ein Stück Software braucht, der AI sagen: bau mir das. Die AI baut es. Und wenn ich es nicht mehr brauche, wird es weggeworfen. Wofür hebe ich den Code überhaupt auf?

In wenigen Jahren, wenn die Entwicklung so weitergeht, baut mir die AI die Anwendung, die ich gerade brauche, in Echtzeit nebenher. Wenn ich kurz warten muss: so what? Sobald ich den Service nicht mehr brauche oder ein neues Feature will, wird das ganze Ding einfach neu gebaut. Was soll es? Wo ist das Problem?

Junioren, Ausbildung und der Druck auf die Sozialsysteme

Jetzt zu sagen: liebe Leute, lernt alle naturwissenschaftliche Fächer, Mathematik, Physik. Ja, das ist gut. Aber auch da wird AI eine Rolle spielen, und ich glaube, wir sind näher an einer kritischen Stelle dran als wir denken. In einigen Jahren weiß vielleicht niemand mehr, wie etwas gebaut wurde, wenn die Leute, die es noch wirklich verstanden haben, aus dem Berufsleben verschwunden sind.

Guckt in die Softwareentwicklung. Da werden im Grunde keine Junioren mehr eingestellt. Fachinformatiker Anwendungsentwicklung: wer macht diesen Ausbildungsberuf noch? Wer bildet diese Leute noch aus? Im Moment braucht man seniorige Menschen, die diese AI bedienen können. Die Aufgaben, die ein Junior oder ein Azubi gemacht hat, sind jetzt schon von der AI übernommen. Und das wird weitergehen.

Roboter werden trainiert, um Arbeiten zu übernehmen. Auch schön. Wenn wir nicht mehr selber arbeiten müssen, ist das doch toll. Der einzige Punkt, der mir Bauchschmerzen macht: aus meiner Sicht ist diese Zeit, die uns jahrhundertelang begleitet hat, Arbeitszeit gegen Geld, irgendwie vorbei. Wir brauchen also eine andere Lösung, wie wir Einkommen sicherstellen, um weiter ordentlich zu leben. Denn Menschen sind eine Spezies, die selten den Hals vollkriegt. Sie wollen immer mehr, immer besser. Und das funktioniert halt nicht.

Diese Lösung muss außerdem nicht nur für Europa, Deutschland oder die USA funktionieren, sondern für die ganze Welt. Wir alle haben in Anführungszeichen das gleiche Problem.

Politik und Gesellschaft kommen nicht hinterher

Wenn ich mir die Welt so angucke, sehe ich im Moment kaum Zusammenarbeit, kein gemeinsames Ziehen an einem Strang. Nicht auf internationalem Level. Da treten sich Leute aus irgendwelchen Gründen gegenseitig vor die Schienbeine. Ich will die einzelnen Konflikte nicht werten. Aber ich glaube nicht, dass wir so sinnvoll nach vorne kommen.

Auch auf kleinerem Level: wie viele tolle Geschichten aus dem Schwarzbuch der Steuerzahler oder bei extra 3 hat jeder schon bewundert. In der deutschen Politik brauchen schon Kleinigkeiten Ewigkeiten. Da ist auf einer Brücke einfach mal 18 Jahre Baustelle und es ist immer noch nicht fertig. Wie sollen wir mit solchen Strukturen ein Problem dieser Größenordnung schaffen?

Ich will damit nicht sagen, dass die alle wegmüssen. Aber ich glaube, wir haben das Problem an dieser Stelle noch nicht einmal verstanden. Wer diesen Beitrag liest, sieht das wahrscheinlich ähnlich wie ich, das ist meine Blase. Aber wenn ich später im Lidl stehe und von links nach rechts gucke, und das ist keine Wertung, leben viele Menschen einfach in anderen Themenfeldern. Nicht in einer anderen Realität, sie haben andere Punkte, die sie bewegen. Wie sehr uns AI gerade überrollt, kommt da kaum an. Es ist alles noch zu neu.

Auch unser Bundeskanzler und die aktuelle Regierung, darüber kann man sich streiten. Manche Sachen machen sie gut, manche schlecht. Niemand ist perfekt. Aber viele dieser Menschen sind in einem Alter, und nein, ich sage nicht, dass man es nur deshalb nicht verstehen kann, weil man älter ist. Aber ich würde behaupten: das Thema AI und die Frage, was das gerade für die Welt bedeutet, wirklich zu greifen, wird mit zunehmendem Alter schwieriger. Einfach schwieriger.

Wie damals bei der Elektrizität in den Häusern

Wenn man jetzt überlegt was man tun sollte, sagen viele: ich reite den AI-Hype-Train, ich gehe voll rein und mache nur noch AI. Das ist auch richtig. Leute, die Agentic Engineering oder Prompt Engineering richtig für sich adaptiert haben, sind im Moment extrem gefragt. Die haben gerade Hochzeit.

Aber wenn ich auf den Stand der Modelle schaue, sind wir trotzdem noch ganz am Anfang. Es ist eher so, als wären gerade die ersten Autos gekommen. Oder noch passender: als die Elektrizität in normalen Häusern eingeführt wurde. Das war gefährlich. Sicherungen? Mit Stoff umwickelte Drähtchen. Keine richtige Erdung. Keiner wusste, wie das wirklich funktioniert. Da ist viel schiefgegangen. Man musste extra vorsichtig sein und Dinge dreimal kontrollieren, damit nichts brennt.

Dann kamen mehr Regeln. Mehr Sicherheit. Es hat sich alles weiterentwickelt. Heute passieren auch noch Unfälle mit Elektrizität, aber im Grunde ist die Technik in unserer Gesellschaft so weit angekommen, dass man kein Super-Fachexperte mehr sein muss, um mit dem Waffeleisen sicher Waffeln zu machen. Steckdose, los geht’s. Und wenn der Defekt im Gerät ist, greifen Schutzmechanismen mit hoher Wahrscheinlichkeit.

Auf AI gemünzt: wir stehen gerade am Anfang dieses Prozesses. Die Experten, die im Moment full commitment reingegangen sind, profitieren gerade. Klar wird da auch mal etwas schiefgehen. Aber das sind die Leute, die ihre Hochzeit haben. Bis zu dem Moment, wo die Technik so weit ist, dass es einfach jeder kann. Wirklich jeder.

Und weil AI sich so schnell weiterentwickelt, wird das nicht lange dauern. Selbst jetzt zu sagen: ich mache Deep Dive in AI und bin in einem oder zwei Jahren der absolute Profi: schon auf dem Weg dahin wird man feststellen, dass man gar nicht mehr so tief einsteigen muss, weil es fast jeder kann.

Das sieht man an tausend Kleinigkeiten. Welche Skills und Abhängigkeiten ich mir vor einem Jahr noch in meinen Claude Code eingebaut habe und wie sehr sich das alles allein weiterentwickelt hat. Wie gut ich die größeren neueren Modelle jetzt schon auf Dinge loslassen kann. Alles nicht perfekt. Nichts davon kann ich zu 100 Prozent unbeaufsichtigt laufen lassen. Aber die Veränderung in diesem einen Jahr ist brutal.

Das sieht auch jeder, der sich AI-Videos anschaut. Will Smith isst Nudeln, damals 2023 oder 2024, und was generiert AI heute für Filmchen? Wenn man durch Social Media oder YouTube scrollt: wie viele AI-Geschichten sind da inzwischen drin, und wie viele davon erkennt man noch als AI? Die meisten ja, manchmal muss ich zweimal hingucken. Bei längeren Videos ist es einfacher. Aber so ein YouTube Short, runtergerechnet auf schlechte Kameraauflösung, vielleicht im Stil eines Bodycam-Shots, da wird es schon schwierig.

Nicht AI ist das Problem, wir sind es

Wie gesagt: AI ist nicht das Problem. Das Problem ist, wie wir Menschen damit umgehen. Wie wir es nicht schaffen, zusammen in eine Richtung zu gehen. Wie wir es nicht schaffen, als Gemeinschaft eine Lösung zu finden. Das ist viel eher das Problem als zu sagen, die AI wird uns alle töten. Das können wir selber am besten.

Was ich daraus mache, und warum ich keine Lösung habe

Was machen wir jetzt daraus? Ich versuche, im Thema zu bleiben. Ich versuche, AI dort einzusetzen, wo sie mich unterstützt und mir hilft. Ich versuche, ein Ohr an der Entwicklung zu halten, auch wenn ich sie nicht wirklich komplett durchdringen kann. Es ist einfach zu viel und zu schnell. Selbst Vollzeit würde mich überfordern.

Ich stelle mich darauf ein, die Systeme, die ich baue und betreibe, mit mehr als einer Sicherheitshürde auszustatten. Ich plane sie so, dass sie kein Problem damit haben, regelmäßig und wirklich regelmäßig Patches zu bekommen. Ich denke sie außerdem so, dass sie von AI-Systemen selbst gebaut, weiterentwickelt, betrieben und überwacht werden können. Das wird mit eingeplant.

Was das große, allgemeine Problem angeht: ich habe keine Lösung. Wirklich keine. Hinzugehen und Entscheidungsträgern das zu erklären, ich glaube nicht, dass ich diese Leute erreichen werde. Vielleicht ist das mein Problem. Technisch bin ich gut, das würde ich mir jetzt einfach mal unterstellen. Aber ich bin vielleicht nicht in der Lage, das vernünftig an den Mann zu bringen.

Ich habe schon mehrfach erlebt, wie ich versucht habe, IT-Security-Probleme möglichst einfühlsam und auf einfachem Level zu erklären, und trotzdem auf taube Ohren gestoßen bin. Für viele Leute, die nicht in der Technik drin sind, ist das einfach zu abstrakt und zu schlecht greifbar. Das ist wahrscheinlich ein Manko bei mir. Ich kriege es nicht so weit heruntergebrochen, dass es für Menschen ohne Tech-Background wirklich anfassbar wird.

Was hört man dann? Vielleicht auch nur, weil sie nichts anderes sagen können: das wird schon. Es wird etwas im Markt geben. Es schafft ja auch neue Jobs. Laberlaber. Da sind wir uns vermutlich einig: das wird nicht der Fall sein. Klar, ein paar neue Spezialjobs werden entstehen. Aber die Masse an Menschen, deren Arbeitskraft plötzlich nicht mehr gebraucht wird, weil sie von AI übernommen wurde, kommt nicht einfach in diese neuen Spezialbereiche hinein. Das wird nicht reichen für alle, die plötzlich aus dem Regal fallen.

Und weil unsere ganzen Sozialsysteme darauf aufgebaut sind, dass viele Leute einzahlen und Steuern zahlen, sehe ich da ein Problem. Ich habe keine Lösung dafür. Mir fällt nichts ein, was funktionieren könnte.

Vielleicht bin ich ein bisschen schwarzmalend. Aber ich glaube nicht, dass wir das gut hinkriegen. Ich mache mir Sorgen, was uns in den nächsten 15 bis 20 Jahren erwartet. Das wird extrem spannend. Aber die wenigsten Dinge daran geben mir ein gutes Gefühl.

Trotzdem können wir uns jetzt nicht alle ein Loch in den Garten buddeln und uns da hineinsetzen. Wir müssen weitermachen und das Beste aus dem ganzen Thema herausholen. Früher oder später wird es auch bei den Entscheidern ankommen. Sie werden es verstehen, oder sie werden die Augen nicht mehr davor verschließen können. Aber selbst dann glaube ich nicht, dass sie eine echte Lösung finden werden.

Siehe auch

Wie seht ihr das? Schreibt es gerne in die Kommentare oder per fragen.

Postfix 3.11.1 mit OpenSSL 3.5: Post-Quantum-TLS jetzt nativ — meine alten Workarounds dürfen raus

Postfix 3.11.1 mit OpenSSL 3.5 – Post-Quantum TLS (X25519MLKEM768) mit kleinem ClientHello durch Delayed Key Share

Kleines Update zur Mini-Saga rund um Post-Quantum-TLS auf Postfix. Zur Erinnerung: Im Original-Beitrag vom Februar stand tls_eecdh_auto_curves noch global in der main.cf, mit X25519MLKEM768 an erster Stelle. Im Nachtrag vom 1. April im selben Beitrag kam dann die Trennung in master.cf, weil die globale Variante einen ClientHello jenseits 1400 Bytes erzeugt und damit gegen manche Zielserver auf die Nase fällt. Dank smtp_tls_security_level = may fällt Postfix in dem Fall stillschweigend auf Plaintext zurück, eure Mail geht raus, aber unverschlüsselt. Klingt akademisch, ist es nicht.

Mit dem letzten regulären pkg upgrade auf FreeBSD 15.0-RELEASE-p7 ist Postfix 3.11.1 eingezogen. Damit erledigen sich beide Workarounds. Der Built-in-Default ist jetzt korrekt, und der ganze master.cf-Override-Block aus dem April darf ersatzlos raus. Wer beide Vorgängerbeiträge nachgebaut hat, darf jetzt rückwärts wieder aufräumen — mit zwei postfix reload ist man durch.

Was Postfix 3.11.1 mitbringt

Postfix 3.11.1 setzt den Built-in-Default für tls_eecdh_auto_curves auf:

tls_eecdh_auto_curves = ?X25519MLKEM768:DEFAULT

Das Fragezeichen vor dem Gruppennamen ist OpenSSL-3.5+ Syntax für Delayed Key-Share, Postfix reicht das 1:1 durch. Bedeutung: X25519MLKEM768 wird in der TLS-Extension supported_groups annonciert, der eigentliche KeyShare aber NICHT vorab im ClientHello generiert. Der KeyShare wird erst materialisiert, wenn der Server per HelloRetryRequest gezielt danach fragt. ClientHello bleibt damit klein, MLKEM wird trotzdem ausgehandelt sobald die Gegenstelle es unterstützt — und fällt sauber auf eine klassische Kurve zurück wenn nicht.

Damit erledigt sich die Begründung für die Client/Server-Trennung aus dem April-Update. Inbound und Outbound können denselben Default nutzen — kein Bloat in eine Richtung, kein Verzicht auf PQC in die andere.

master.cf aufräumen

Aus master.cf werden alle -o tls_eecdh_auto_curves=... Overrides ersatzlos entfernt. Im konkreten Setup hier waren das fünf Stellen: smtp/inet, submission/inet, der eigene 2525/inet-Listener, smtps/inet und smtp/unix für Outbound. Diese Zeilen alle raus:

smtp      inet  n       -       n       -       -       smtpd
  -o tls_eecdh_auto_curves=X25519MLKEM768,X25519,prime256v1,secp384r1   # raus

submission inet n       -       n       -       -       smtpd
  -o tls_eecdh_auto_curves=X25519MLKEM768,X25519,prime256v1,secp384r1   # raus

smtps     inet  n       -       n       -       -       smtpd
  -o tls_eecdh_auto_curves=X25519MLKEM768,X25519,prime256v1,secp384r1   # raus

smtp      unix  -       -       n       -       -       smtp
  -o tls_eecdh_auto_curves=X25519,X25519MLKEM768,prime256v1,secp384r1   # raus

In der main.cf wird ebenfalls nichts mehr explizit gesetzt — der Built-in-Default greift. Verifikation:

# postconf tls_eecdh_auto_curves
tls_eecdh_auto_curves = ?X25519MLKEM768:DEFAULT

# postconf -P | grep tls_eecdh_auto_curves
(leer -- kein Override aktiv)

Anschließend postfix reload. Kein Restart, kein Service-Ausfall, keine offenen Verbindungen verloren. Dovecot bleibt unverändert, dort sieht es weiter so aus:

ssl_curve_list = X25519MLKEM768:X25519:prime256v1:secp384r1

Dovecot ist nur Server, kein Outbound-Client. Da gibt es kein ClientHello-Bloat-Problem.

Was DEFAULT konkret enthält

Hinter dem schlichten Wort DEFAULT versteckt OpenSSL 3.5.4 eine konkrete Liste. Ich habe sie aus dem extension_type=supported_groups-Block eines Test-Handshakes herausgezogen:

X25519MLKEM768 (4588)   # mit ?-Prefix vom davorgestellten Eintrag, dedupliziert
X25519         (29)
secp256r1      (23)     # prime256v1
X448           (30)
secp384r1      (24)
secp521r1      (25)
ffdhe2048      (256)
ffdhe3072      (257)

Damit ist klar warum man den Default in der Regel ungeändert lässt — er enthält MLKEM768 hybrid an erster Stelle, alle gängigen klassischen Kurven dahinter und ein paar FFDHE-Gruppen als Backup. Wer es minimaler will und auf FFDHE verzichten kann, setzt explizit ?X25519MLKEM768:X25519:prime256v1:secp384r1. Notwendig ist das nicht.

Verifikation #1: ClientHello-Größen mit openssl s_client

Um das Delayed-Key-Share-Verhalten ohne Glaubensfrage zu prüfen, vier verschiedene Group-Listen gegen einen MLKEM-fähigen MX gefahren — Gmail tut sich da als Test-Target ganz gut. Die Schleife direkt zum Nachstellen:

for groups in 
    '?X25519MLKEM768:DEFAULT' 
    'X25519MLKEM768:X25519:prime256v1:secp384r1' 
    'X25519:X25519MLKEM768:prime256v1:secp384r1' 
    'X25519'; do
  echo "=== groups=$groups ==="
  openssl s_client -connect gmail-smtp-in.l.google.com:25 -starttls smtp 
    -groups "$groups" -msg </dev/null 2>&1 
    | grep 'Handshake.*ClientHello'
done

In der -msg-Ausgabe steht pro Handshake-Record eine Zeile wie

>>> TLS 1.3, Handshake [length 014e], ClientHello

Die hex-Length ist die Größe des TLS-Records. Tabellarisch:

Group-ListeInitial ClientHelloNach HRR
?X25519MLKEM768:DEFAULT (Postfix 3.11 Default)334 Bytes (0x014e)1518 Bytes (0x05ee)
X25519MLKEM768:X25519:prime256v1:secp384r1 (alt-Inbound)1510 Bytes (0x05e6)
X25519:X25519MLKEM768:prime256v1:secp384r1 (alt-Outbound)326 Bytes (0x0146)1510 Bytes (0x05e6)
X25519 (klassisch ohne PQC)320 Bytes (0x0140)

Interpretation: Der neue Default in Zeile 1 verhält sich praktisch identisch zum alten Outbound-Workaround in Zeile 3 — kleines Initial-ClientHello mit nur klassischen KeyShares, MLKEM wird erst über HelloRetryRequest aktiviert. Der alte Inbound-Style in Zeile 2 hingegen pusht den rund 1184 Byte großen ML-KEM-768-PublicKey schon im ersten ClientHello mit und sprengt damit die 1400-Byte-Schwelle, an der einige Mail-Frontends regelmäßig stoppen. Genau das Verhalten, das im Original-Beitrag und im April-Update als Problem identifiziert wurde — jetzt nativ vermieden. Ich muss ja gerade etwas grinsen …

Meine Frau ist immer mal wieder so cool und liest mir meine neuen Beiträge vor. So höre ich sie noch einmal, und ihr Feedback ist mir sehr wichtig.

Sie versteht aber im Grunde kein Wort und ersetzt jegliche Codeblöcke oder zu technische Specs immer durch „bla / irgendwas“.

Ich glaube, dieser Beitrag wird einer der schwierigeren. Dennoch wird sie ihn für mich lesen — und das ist toll. 😀

Danke!

Verifikation #2: tcpdump auf dem Wire

Wer dem OpenSSL-Output nicht glaubt, kann sich das ClientHello auch direkt vom Draht holen. Damit der Capture funktioniert, sind zwei Stolpersteine zu beachten:

  • tcpdump muss auf dem Host laufen, nicht in der Jail. In FreeBSD-Jails ist der direkte BPF-Zugriff aufs Interface defaultmäßig abgeschaltet (Packet capture is not supported on that device). tcpdump auf der Host-Seite sieht den Jail-Verkehr aber sauber über das geteilte Interface.
  • openssl s_client greift per Default zu IPv6, wenn der Zielhost AAAA-Records hat. Ein reiner IPv4-Filter bleibt dann leer (0 packets captured / 689 packets received by filter — ich gestehe, der Moment war kurz verwirrend). Lösung: openssl mit -4 zwingen oder den Filter dual-stack auslegen. Ja klar, ist ja eine selbstverständliche Kleinigkeit. Sehe ich auch so. Nur warum habe ich dann fünf Minuten lang mit komplett leerem Blick wie ein Eichhörnchen auf meinen Monitor geschaut und nicht verstanden, warum ich keine Packets sehe? Bis zu diesem Moment habe ich mich ja für einen kurzen Augenblick cool gefühlt, weil ich dachte, das alles so weit verstanden zu haben. Tja, und dann? Dann fünf Minuten lang: „Kein Anschluss unter dieser Nummer …“

Konkret im Test gelaufen (smtp-Jail mit IPv4 148.251.30.205, IPv6 2a01:4f8:262:4716::25, Interface igb0):

tcpdump -i igb0 -n -s 0 -c 8 -w /tmp/ch.pcap 
  'dst port 25 and (host 148.251.30.205 or host 2a01:4f8:262:4716::25)' &

jexec smtp openssl s_client -4 
  -connect gmail-smtp-in.l.google.com:25 -starttls smtp 
  -groups '?X25519MLKEM768:DEFAULT' </dev/null

Anschließend pcap auslesen:

tcpdump -nn -tttt -r /tmp/ch.pcap        # Paketsequenz
tcpdump -nn -r /tmp/ch.pcap -X            # Hex-Dump für TLS-Record-Inspektion

Gekürzte Sequenz:

18:35:59.443  IP 148.251.30.205.10420 > 142.251.127.27.25: Flags [S],   length 0
18:35:59.448  IP                                            Flags [.],  length 0
18:35:59.525  IP                                            Flags [P.], length 23: SMTP: EHLO ...
18:35:59.541  IP                                            Flags [P.], length 10: SMTP: STARTTLS
18:35:59.554  IP                                            Flags [P.], length 339: SMTP

Das letzte Paket mit 339 Byte Payload enthält das ClientHello. Hex-Auszug aus tcpdump -X:

0x0030:  ...1603 0101 4e 01 0001 4a 03 0354
0x0040:  045a 83dd 0481 ee15 c537 75f2 6b38 f360
...

Aufgeschlüsselt:

0x16     -- TLS Record Type Handshake
0x0301   -- TLS Version (legacy in TLS 1.3 ClientHello)
0x014e   -- TLS Record Length 334 Bytes
0x01     -- Handshake Type ClientHello
0x00014a -- ClientHello-Body 330 Bytes

339 Byte TCP-Payload teilen sich auf in 5 Byte TLS-Record-Header und 334 Byte Body. Das passt locker in ein einziges TCP-Segment, weit unter der 1400-Byte-Schwelle. Damit ist das ?-Präfix-Verhalten auch auf dem Wire bestätigt.

Verifikation #3: eigene Inbound-Seite von extern

Zum Abschluss von einem zweiten FreeBSD-Host aus — also nicht aus der smtp-Jail selbst, sondern als komplett externer Client — gegen alle relevanten Ports getestet:

for entry in 25:smtp 465: 587:smtp 993: 4190:sieve; do
  port=${entry%:*}; starttls=${entry#*:}
  host=smtp.kernel-error.de
  case $port in 993|4190) host=imap.kernel-error.de ;; esac
  if [ -z "$starttls" ]; then args="-connect $host:$port"
  else args="-connect $host:$port -starttls $starttls"; fi
  echo "--- Port $port ---"
  echo QUIT | openssl s_client $args -brief 2>&1 | grep -E 'Negotiated|Verification:'
done

Ergebnis für jeden der fünf Ports identisch:

Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768

Postfix-Inbound auf 25/465/587 und Dovecot auf 993/4190 verhandeln durchweg X25519MLKEM768. Der Default greift für smtpd genauso wie für smtp outbound, ohne dass irgendwo noch eine Trennung händisch erzwungen werden muss.

Fazit

Mit Postfix 3.11 und OpenSSL 3.5 ist sowohl die main.cf-globale Variante aus dem Original-Beitrag als auch die master.cf-Trennung aus dem April-Update Geschichte. Der Built-in-Default ?X25519MLKEM768:DEFAULT liefert genau das Verhalten, das ich vorher manuell aufgebaut hatte: kleiner ClientHello outbound, MLKEM via HelloRetryRequest, klassischer Fallback wo nötig.

Konkret: wer den Original-Beitrag oder den April-Update nachgebaut hat, kann nach dem pkg-Upgrade auf Postfix 3.11.x die tls_eecdh_auto_curves-Einträge ersatzlos rausnehmen — aus main.cf und/oder master.cf — und mit postfix reload aktivieren. openssl s_client -msg und ein kurzer tcpdump bestätigen anschließend, dass das initiale ClientHello tatsächlich klein bleibt und MLKEM über HRR aushandelt.

Drei Iterationen, ein Default, der am Ende einfach passt. So darf das gerne häufiger laufen.

Siehe auch: Post-Quantum TLS für E-Mail (Original-Beitrag mit April-Nachtrag), Post-Quantum TLS für Nginx, Post-Quantum TLS auf Nginx: 15 Tage $ssl_curve ausgewertet und der Mailinglisten-Thread auf postfix-users, der die ganze Saga überhaupt erst angestoßen hat.

Wie immer: bei Fragen, fragen.

Post-Quantum TLS auf Nginx: 15 Tage $ssl_curve ausgewertet, wer macht mit?

Post-Quantum TLS auf Nginx – Auswertung der Key-Exchange-Gruppen (X25519MLKEM768 vs. klassisch)

Vor einigen Wochen habe ich Nginx hier auf X25519MLKEM768 umgestellt und den Weg dorthin in einem eigenen Beitrag dokumentiert: Post-Quantum TLS für Nginx auf FreeBSD 15. Am Ende des Beitrags stand ein kleines Versprechen. Ich erweitere das Logging um die ausgehandelte Key-Exchange-Gruppe, lasse das ein paar Wochen laufen und werte dann aus, wer was tatsächlich spricht. Das ist jetzt eingelöst.

Der Messaufbau, in zwei Zeilen

Nginx kennt die Variable $ssl_curve. Die ist seit Ewigkeiten verfügbar und liefert pro Verbindung zurück, welche Kurve bzw. Gruppe beim TLS-Handshake benutzt wurde. Also X25519MLKEM768, X25519, secp384r1, prime256v1 und so weiter. Im Log-Format einfach nach $ssl_cipher eingehängt, einen Reload in den Nginx geschickt, fertig.

log_format goa_ext
  '$remote_addr - $remote_user [$time_local] '
  '"$request" $status $body_bytes_sent '
  '"$http_referer" "$http_user_agent" '
  '$host $server_protocol $scheme '
  '$request_time $upstream_response_time $upstream_status $upstream_addr '
  '$ssl_protocol $ssl_cipher $ssl_curve $upstream_cache_status';

Nach rund 15 Tagen liegen grob 180.000 HTTPS-Handshakes im Log, verteilt über alles, was so an HTTPS-Clients vorbeikommt. Das ist genug Masse, um ein paar belastbare Muster zu sehen, ohne dass einzelne Ausreißer das Gesamtbild kippen. Exakte Besucherzahlen werde ich in diesem Beitrag bewusst nicht auflisten, das ist auch nicht das Thema. Mich interessiert die relative Verteilung. Wer macht PQ, wer macht es nicht?

Die eine Zahl vorweg

Über alle HTTPS-Verbindungen (Browser, Bots, Crawler, Monitore, alles) sieht die Verteilung der Kurven so aus:

X25519MLKEM768   57,0 %   <- Post-Quantum-Hybrid
X25519           40,0 %   <- klassisches TLS 1.3
secp384r1         2,2 %   <- meist TLS 1.2
prime256v1        0,8 %   <- meist TLS 1.2

Klingt erstmal ordentlich. 57 % PQ über das komplette Gemisch, 98 % TLS 1.3, lediglich 2 % TLS 1.2. Aber in dieser Zahl stecken ein paar Dinge versteckt drin, die man erst sieht, sobald man die User-Agents grob auseinandersortiert. Ich habe das in ein paar Kübel geworfen: Browser, AI-Crawler, klassische Suchmaschinen, SEO-Spider, Fediverse-Software, RSS-Reader, Monitore und CLI-Tools. Nicht perfekt, aber brauchbar.

Browser, die Post-Quantum-Avantgarde

Bei echten Browsern (Chrome, Firefox, Safari, Edge) sieht es deutlich freundlicher aus. Zusammengenommen sprechen rund 77 % der Browser-Verbindungen bereits MLKEM768. Aufgeschlüsselt:

Firefox    87 % PQ   <- klarer Champion
Safari     75 % PQ
Edge       73 % PQ
Chrome     72 % PQ
Opera       2 % PQ   <- haengt seltsam weit hinten

Firefox liegt erkennbar vorn. Nicht weltbewegend weit, aber deutlich. Mozilla hat MLKEM768 früh aktiviert und nutzt standardmäßig die Hybrid-Gruppe, wenn der Server sie anbietet. Chrome hängt etwas hinter den anderen und ich vermute, das liegt an den ganzen älteren Chrome-Builds, die in Embedded-Devices, WebViews und seltsamen Apps stecken und auch als Chrome im User-Agent stehen. Opera dagegen nimmt fast immer nur klassisches X25519. Keine Ahnung warum, schaue ich mir vielleicht mal gesondert an.

Das Schöne daran ist: Ich habe für diese 77 % exakt nichts getan außer die Nginx-Konfiguration anzupassen. Kein Opt-in, kein Banner, keine Weiche. X25519MLKEM768 steht ganz oben in ssl_ecdh_curve und wird genommen, wenn der Client es kann. Der Rest ist reines Client-Upgrade-Verhalten. Das ist eigentlich die schönste Erkenntnis aus der ganzen Auswertung. Wenn die Serverseite rechtzeitig aktualisiert wird, zieht die Clientseite fast geräuschlos nach.

AI-Crawler, flächendeckend bei null

Jetzt kommt der Teil, den ich so nicht erwartet hätte. Die großen AI-Crawler holen sich hier regelmäßig Inhalte ab (siehe auch von SEO zu AEO: llms.txt und llms-full.txt), der TLS-Stack dahinter ist bei praktisch allen auf dem Stand von vor zwei Jahren:

OpenAI GPTBot             0 % PQ    -> X25519
Anthropic ClaudeBot       0 % PQ    -> X25519
Meta AI (ExternalAgent)   0 % PQ    -> X25519
PerplexityBot             0 % PQ    -> X25519
ChatGPT-User              0 % PQ    -> X25519
OpenAI SearchBot          0 % PQ    -> X25519
GoogleOther               0 % PQ    -> X25519
Amazonbot                 0 % PQ    -> TLS 1.2 + secp384r1

Amazon ist nochmal ein eigenes Kapitel. Amazonbot kommt hier ausschließlich mit TLS 1.2 und secp384r1. Das ist ein TLS-Stack, den ich persönlich bei einem Unternehmen, das Cloud-Sicherheit verkauft, nicht mehr erwartet hätte. Aber Messungen lügen nicht.

Zwei echte Ausnahmen gibt es:

ByteDance Bytespider     91 % PQ   <- ueberraschend
DuckAssistBot           100 % PQ   <- auch ueberraschend

Auf diese zwei hätte ich nicht gesetzt. TikToks Crawler macht zu über 90 % PQ, DuckDuckGos AI-Helper zu 100 %. Wer hätte das gedacht.

Klassische Suchmaschinen, auch nicht besser

Bei den traditionellen Suchmaschinen ist das Bild fast identisch zum AI-Lager:

Googlebot       0 % PQ
Applebot        0 % PQ
PetalBot        0 % PQ
Baiduspider     0 % PQ
SeznamBot       0 % PQ
Qwant           0 % PQ
YandexBot       1 % PQ
Bingbot         0 % PQ und zu 99,6 % auf TLS 1.2 + secp384r1
DuckDuckBot    84 % PQ   <- der einzige helle Fleck

Besonders bitter: Bingbot. Der läuft hier praktisch ausschließlich auf TLS 1.2 mit secp384r1. Microsofts Produktions-Webcrawler, 2026, mit einem TLS-Stack, den Webauditoren seit Jahren rot anstreichen. Googlebot ist immerhin auf TLS 1.3, aber halt ohne PQ. Der einzige, der fürs Thema etwas tut, ist DuckDuckBot. Respekt dafür.

SEO-Spider, am weitesten hinten

Der traurigste Haufen. AhrefsBot, SemrushBot, MJ12bot, DotBot, Barkrowler, DataForSeoBot, SeekportBot, Vebidoobot, alle zwischen 0 % und 3 % PQ. Vebidoobot kommt komplett auf TLS 1.2 rein. Das sind kommerzielle Produkte, die von Seitenbetreibern dafür bezahlt werden, Webseiten zu analysieren und Empfehlungen auszusprechen. Analysieren tun sie mit einem TLS-Stack, den sie ihren eigenen Kunden vermutlich als kritischen Finding in den Bericht schreiben würden. Kurios.

Fediverse, alles drin je nach Codebase

Seit dem Anschluss ans Fediverse ist das hier die zweitgrößte Traffic-Quelle nach den Browsern, deshalb habe ich mir das extra angeschaut. Mastodon stellt davon den Löwenanteil, weil es schlicht die meisten Instanzen gibt:

Mastodon                  62 % PQ
snac / GoToSocial /
  Friendica / Hubzilla    73 % PQ   <- Go/C-basiert
Misskey / Sharkey / ...   34 % PQ
Akkoma                     0 % PQ
Pleroma                    0 % PQ

Bei Mastodon gibt es eine große Varianz zwischen den Instanzen, weil die Ruby- und OpenSSL-Version des jeweiligen Server-Hosts entscheidet, ob PQ geht. Aktuelle Distribution mit OpenSSL 3.5 oder neuer: dabei. Noch auf OpenSSL 3.0 festhängend: nicht dabei. Der Schnitt liegt bei 62 %, was für ein so diverses Ökosystem schon erstaunlich ordentlich ist.

snac und GoToSocial liegen deutlich höher, weil sie in Go beziehungsweise C geschrieben sind und moderne TLS-Stacks mitbringen. Akkoma und Pleroma (beide Elixir/Erlang) zeigen dagegen gar keine PQ-Adoption. Das hängt an der OpenSSL-Version, die die BEAM-VM dort nutzt. Misskey und die ganzen Forks dazwischen liegen bei rund einem Drittel.

RSS-Reader, unerwartet modern

Hätte ich vorher schätzen sollen, hätte ich RSS-Reader eher am Ende dieser Liste verortet. Alte Technologie, alte Software, wahrscheinlich alter TLS-Stack. Stimmt aber nicht:

Miniflux              100 % PQ   <- Go-basiert
FreshRSS               82 % PQ
NextCloud-News         81 % PQ   <- zahlenmaessig vorn
Tiny Tiny RSS          37 % PQ
Inoreader               0 % PQ
Feedly                  0 % PQ   <- haengt auch hinten

Miniflux macht 100 % PQ, weil es in Go geschrieben ist und ab Go 1.24 MLKEM768 standardmäßig im TLS-Stack sitzt. FreshRSS und NextCloud-News laufen meist auf aktuellen PHP/curl-Umgebungen und ziehen MLKEM darüber mit. Feedly als kommerzieller Anbieter: 0 %. Also genau das Gegenteil dessen, was ich erwartet hätte. Self-Hosted ist hier eindeutig moderner unterwegs als die SaaS-Variante.

CLI-Werkzeuge und Kuriositäten

go-http-client     93 % PQ   <- Go 1.24+
curl               30 % PQ   <- je nach OpenSSL-Build
python-requests    16 % PQ
Node (axios)       61 % PQ
okhttp              0 % PQ
wget                0 % PQ
Twitterbot         97 % PQ   <- unerwartet weit vorn

Das Muster ist eigentlich immer dasselbe. Der TLS-Stack der Laufzeitumgebung entscheidet. Go ≥ 1.24 macht es automatisch, moderne Node-Versionen bringen einen aktuellen OpenSSL mit, Python und curl hängen an der Distribution. okhttp auf Android und wget: Fehlanzeige.

Kleiner Spaß am Rande. Twitterbot ist zu 97 % auf MLKEM. Also ausgerechnet der Link-Preview-Crawler von X/Twitter ist moderner unterwegs als alle anderen Social-Preview-Bots zusammen. WhatsApp: 4 %, Discord: 0 %, LinkedIn ist kaum vertreten. Warum Twitter? Keine Ahnung. Vermutlich ein moderner Go-Client unter der Haube.

TLS 1.2, wer hängt noch ganz unten?

2 % des Traffics kommen komplett mit TLS 1.2, also ohne jede Chance auf PQ. Die Top-Kandidaten sind:

vebidoobot (SEO-Spider)
Amazonbot
DotBot, MJ12bot (SEO)
theoldreader.com (RSS-SaaS)
http.rb/Mastodon auf aelteren Instanzen
ein paar vereinzelte Alt-Browser und Skype-Link-Previews

Also fast ausschließlich kommerzielle Crawler, deren TLS-Library vor der ganzen 1.3-Welle kompiliert wurde, und ein paar ältere Mastodon-Instanzen. Reale Leser sind so gut wie nicht betroffen. Wer heute einen Feed-Reader mit TLS 1.2 nutzt, hat vermutlich andere Probleme zuerst zu lösen.

Gibt es einen Trend in den 15 Tagen?

Nicht wirklich. Der PQ-Anteil pro Tag schwankt zwischen 46 % und 65 %, je nach Traffic-Mix (mehr Browser an Wochentagen, mehr Crawler nachts und am Wochenende). Einen klaren Aufwärts- oder Abwärtstrend gibt es nicht. Wir sind im Plateau. Die Browser haben den Sprung gemacht, der Rest der Welt noch nicht. Der nächste Sprung kommt, wenn die großen Crawler-Betreiber ihre Go-, Python- oder Node-Stacks aktualisieren oder OpenSSL 3.5+ in den gängigen Distributionen ankommt. Bei Debian Trixie, RHEL 10 und Ubuntu 26.04 sollte das passieren, dann reden wir in einem Jahr nochmal.

Was ich daraus mitnehme

  • Echte Besucher sind bei MLKEM768 schon sehr weit. Browser-Entwicklung funktioniert erstaunlich gut.
  • AI- und SEO-Crawler sind deutlich hinter dem, was man erwarten würde. Cutting Edge in der Marketing-Abteilung, Uralt-Stack im Maschinenraum.
  • Fediverse-Software ist so divers wie ihre Codebasen. Bei Mastodon entscheidet die Instanz, nicht die Software.
  • RSS-Reader sind unerwartet modern unterwegs. Self-Hosted schlägt SaaS auch hier.
  • Am Ende hängt fast alles an der OpenSSL-Version unter der Anwendung. Wieder mal.
  • Bingbot auf TLS 1.2 ist 2026 trotzdem noch bemerkenswert.

Was mich am meisten gefreut hat: Ich habe keinerlei Reibungsverluste gesehen. Kein Client ist wegen PQ gestolpert, keine Verbindung ist fehlgeschlagen, die vorher funktioniert hätte. Der Handshake wählt einfach die beste gemeinsame Gruppe und gut ist. Deshalb nochmal der Appell an alle, die den Einstellungs-Beitrag noch vor sich haben: Das ist wirklich ein Zweizeiler. Macht es einfach.

Nächster Check in ein paar Monaten

Ich lasse das Logging weiterlaufen und schaue in ein paar Monaten nochmal rein. Was mich besonders interessiert:

  • Wann machen OpenAI, Anthropic und Meta ihren Crawler modern? Bleibt das auf Jahre bei 0 % PQ, oder kommt da plötzlich ein Sprung?
  • Schafft es OpenSSL 3.5 in die nächsten Long-Term-Release-Linuxe, und wie schnell ziehen Mastodon-Instanzen nach?
  • Springt Googlebot irgendwann auf PQ um? Bisher nein. Wenn das kommt, dürfte das unmittelbar sichtbar sein.
  • Kommt TLS 1.3 für Amazonbot? Zumindest das wäre ein Anfang.

Siehe auch

Wie immer: Bei Fragen, fragen.

« Ältere Beiträge

© 2026 -=Kernel-Error=-RSS

Theme von Anders NorénHoch ↑