knuspermagier.de

Hallo. Ich bins! Philipp!

Einen einfachen ActivityPub-Server bauen, oder: der Kirby-Blog als Fediverse-Teilnehmer in vierhundert einfachen Schritten

Wie im letzten Post dazu schon angeteasert, wollte ich noch ein paar Worte dazu verlieren, wie ich in den letzten Wochen diesen Blog ans Fediverse angeschlossen habe. Der Code ist mittlerweile als eine Art Kirby-Plugin auf Github verfügbar.

Vielleicht ist der Code und dieser Post eine kleine Hilfestellung auf der Reise durch den Dschungel der mittelmäßigen Dokumentation zum Thema Fediverse, Mastodon und so weiter.

Inhaltsverzeichnis

Fediwas?

Für alle, die sich in den letzten Wochen nicht so sehr damit beschäftigt haben, nochmal eine kurze Zusammenfassung, was diese ganzen Wörter eigentlich bedeuten.

WebFinger

Aber kommen wir zu den Implementierungsdetails.

Was passiert, wenn man ein Handle, also z.B. wurst@knuspermagier.de in einem Mastodon-Client in die Suchleiste eingibt? Im Idealfall hat natürlich schon jemand anderes nach diesem Handle gesucht und der Mastodon-Server weiß bereits Bescheid und hat alles im Cache. Sollte das nicht der Fall sein, dann werden einige Requests getriggert.

Zuerst kommt das Webfinger-Protokoll zum Einsatz. Der Server ruft also folgende Addresse auf:

https://knuspermagier.de/.well-known/webfinger?resource=acct:wurst@knuspermagier.de

Warum WebFinger? 👈

Warum heißt das so, und was hat es mit meiner Hand zu tun?

Der/die geneigte Linux-User:in, Informatik-Student:in oder allgemein Nerd:in hat den Begriff sicherlich schon öfters gelesen. Name/Finger ist eines der ersten Internetprotokolle. Es ist von 1971 und sollte ermöglichen, Informationen zu Benutzern auf anderen Rechnern einzuholen. Damals, als das Internet noch so klein war, dass man das für eine gute Idee hielt.

Damals, im Informatik-Studium lachten wir noch über den Namen. finger, hihihi. Doch warum heißt das nun so? Jedenfalls hat es vom Gedanken her eher nichts mit irgendwelchen Anzüglichkeiten zu tun, sondern entstand eher durch die weitere Bedeutung von “to finger someone to someone”, was “jemanden bei jemandem verpfeifen” bedeutet.

(Zumindest entnehme ich das so dem Wikipedia-Artikel)


Zurück sollte sowas kommen:

{
    "subject": "acct:wurst@knuspermagier.de",
    "aliases": ["https:\/\/knuspermagier.de\/user\/wurst"],
    "links": [{
        "rel": "self",
        "type": "application\/activity+json",
        "href": "https:\/\/knuspermagier.de\/user\/wurst"
    }]
}

Das war also der erste Part, den ich ich Implementieren musste. Ein statisches JSON zurückgeben, das war schon einmal nicht so schwer. Ich öffnete also die config.php meiner Kirby-Installation und fügte ein paar neue Routen ein und versuchte meinen Blog im Mastodon-Interface zu finden.

Leider erstmal erfolglos. Auch irgendwie verständlich, außer der Gewissheit, dass der Account wohl existiert, weiß Mastodon noch nichts über uns. Alle weiteren Informationen, wie der Anzeigename, der Avatar, die Profil-Beschreibung und alles, damit hat WebFinger gar nix zu tun. Diese Informationen befinden sich alle in einer anderen Datei, die aber im WebFinger-Response in der links-Sektion steckt, der Link zum User-Profil, welches in etwa so aussieht:

{
    "@context": ["https:\/\/www.w3.org\/ns\/activitystreams", "https:\/\/w3id.org\/security\/v1"],
    "id": "https:\/\/knuspermagier.de\/_aplite\/user\/knuspermagier",
    "type": "Person",
    "preferredUsername": "knuspermagier",
    "inbox": "https:\/\/knuspermagier.de\/_aplite\/user\/knuspermagier\/inbox",
    "outbox": "https:\/\/knuspermagier.de\/_aplite\/user\/knuspermagier\/outbox",
    "following": "https:\/\/knuspermagier.de\/_aplite\/user\/knuspermagier\/following",
    "followers": "https:\/\/knuspermagier.de\/_aplite\/user\/knuspermagier\/followers",
    "publicKey": {
        [...]
    },
    "summary": "knuspermagiers Blog",
    "url": "https:\/\/knuspermagier.de",
        [...]
}

Also zweites baute ich nun also eine Route für das User-Profil basierend auf dem ActivityPub-Protokoll. Auch hier kopierte ich erstmal die JSON, die meine Mastodon-Instanz für mich zurücklieferte, änderte nur ein paar Werte und gab es einfach in einer einfachen Kirby-Route aus.

Schwupps, es funktionierte, ich konnte meinen Account in der Suche finden und sogar anklicken. Doch, wenn ich auf “Follow” klickte, passierte noch nichts. Warum auch?

Der Follow-Flow

Klickt man im Interface auf “Follow” sendet der Server eine Activity mit dem Typ Follow an den entsprechenden Server des Accounts, dem man folgen will. Aber wohin genau? Um das herauszufinden schauen wir nochmal in das User-Profil, das wir im letzten Schritt verlinkt haben. Neben Anzeigename und so sind dort drin auch einige URLs hinterlegt, die für die ActivityPub-Kommunikation da sind.

Was wir hier jetzt brauchen ist die inbox. Die dort angegebene URL sollte ein Endpunkt sein, der per POST eine entsprechende JSON-Payload annimmt.

Auch das kann Kirby ja ohne Probleme, also angelegt und so

Nun muss man den Follow noch akzeptieren, indem man eine Accept-Activity zurückschickt. Hier geht der Spaß los, denn alle Requests müssen sinnvollerweise signiert werden.


HTTP Signature

Um sicher gehen zu können, dass eine Follow-Anfrage auch wirklich von der Person kommt, die sie losgeschickt hat, muss man sich natürlich was ausdenken. Die Antwort ist hier, wie immer: Kryptografie! Asymmetrische Verschlüsselung, um genau zu sein.

Die ActivityPub-Spec gibt nicht zwingend vor, dass hier entsprechende Signaturen verwendet werden, empfiehlt es aber. Mastodon verwendet einen Signature-HTTP-Header, zu dem ich nicht wirklich eine passende Spezifikation finde, aber es gibt einige Implementierungen, nicht zuletzt halt im Source Code oder in diesem Blogpost, der leider etwas veraltet ist und nicht mehr zu 100% passt.

Update: Hier ist die Spezifikation, danke Jan!

Der Header besteht aus einem Link zum Public Key, einer Auflistung der HTTP-Header, über die die Signatur erstellt wurde und natürlich die Signatur selber, als Base64-String.

Zusätzlich zur Signatur erfordert Mastodon seit einiger Zeit (jünger als der ursprüngliche Blogpost, daher ist darin noch nicht die Rede davon) einen Digest-Header im Request. Dieser enthält einen Hash der Payload, also des JSON-Objektes. Das Digest ist auch wieder Teil der Signatur, so wird nicht nur sichergestellt, dass der Request von der richtigen Person kommt, sondern auch, dass der Inhalt sich nicht geändert hat


Mit der Erstellung der Signatur hatte ich ein bisschen zu kämpfen, konnte mir es am Ende aber irgendwie passend zusammenbauen. Letztendlich baut man sich einen Plaintext-String auf, der etwa so aussieht:

(request-target) post /user/foo/inbox
host: example.com
date: Sat, 19 Nov 2022 21:41:08 GMT
digest: SHA-256=JmCJNaRNp6RxXiXN4Igngy37Em8WQWCR1AG6d0xT2Uc=

Diesen kann man dann mit openssl_sign signieren. Schon fertig. Tatsächlich funktionierte das bei meinem ersten Versuch auch schon ganz gut. Ich hatte ja einfach verschiedene Response-JSONs von meinen Mastodon-Accounts kopiert und benutzt.

Erst als ich dann später etwas aufräumte und alles etwas schöner und für andere verwendbar baute, ging alles kaputt. Wie nervig. Hier brachte ich einen ganzen frustrierten Abend zu, bis ich irgendwann merkte, dass der PublicKey irgendwie doppelt escaped im JSON landete. Das gute alte versehentliche doppelte Escaping. Wie viele Stunden Lebenszeit hat mich das schon gekostet!

Wichtig ist nämlich dass der Public Key, der im Signatur-Header verlinkt wird, auch vorhanden ist und vom Ziel-Server abgerufen werden kann. In unserem Fall ist der im Feld publicKey im oben besprochenen User-Profil.

Hat man nun alle Bestandteile, also WebFinger- und User-Profil-Response, sowie etwas, das Requests auf die Inbox verarbeiten und beantworten kann, beisammen, ist es auch schon so weit. Man hat einen kleinen Fediverse-Teilnehmer, dem man Folgen kann! Juchu!


Neben der inbox und dem publicKey befinden sich noch einige andere wichtige Links im Profil, z. B. followers und followings, die jeweils eine Liste der Followings und Follower zurück geben. Zusätzlich gibt es auch noch die outbox, die theoretisch alle Aktivitäten enthält, die der User getriggert hat.


Debugging

Spätestens an dieser Stelle fragt man sich sicher, wie man den ganzen Kram am besten debuggen kann. Vor allem beim Follow-Flow und der ganzen Signatur-Geschichte musste ich wild herumprobieren bis alles klappte.

Letztendlich beschränkte ich mich diesmal, mal wieder, auf extensives Logging in diverse Dateien, um zu verstehen, was passiert. Ich dachte mehrfach darüber nach endlich Ray einmal auszuprobieren, war am Ende aber doch schnell genug über den frustrierenden Nichts-geht-Punkt hinaus. Vielleicht beim nächsten Mal!

Ansonsten habe ich zuerst viel im Sourcecode von Mastodon gewühlt und mich sehr über die intelligenten Funktionen von Github gefreut, mit denen man, als wäre man in einer IDE, z.B. zur Definition einer Methode springen kann. Sehr gut.

Irgendwann als es gar nicht mehr weiter ging, setzte ich mir noch schnell eine Mastodon-Instanz auf. Dafür kann man entweder eben “Install Mastodon on Linux” googeln und eine Anleitung mit 20-40 Schritten durchgehen, versuchen die docker-compose.yml aus dem Repository zu nutzen, oder man meldet sich kurz auf Digital Ocean an und nutzt dort das Mastodon-Image, mit dem das alles mit einem Klick funktioniert. Der einzige Wermutstropfen: Es ist relativ teuer, da man nicht die günstigste VM benutzen kann, weil Mastodon doch ein paar Resourcen mehr braucht.

Scope

Spätestens zu diesem Zeitpunkt machte ich mir nochmal Gedanken, was ich hier eigentlich mache. Klar, ich habe irgendwie Spaß mir das ganze Ding zu erarbeiten und mittlerweile verstehe ich ActivityPub und Mastodon ganz gut, aber macht es Sinn das hier als super ausgearbeitetes Plugin zu bauen und zu veröffentlichen? Ich halte die Zielgruppe für relativ gering.

Ich entscheide mich erstmal dafür, noch einzubauen, dass man Posts automatisch als Activity veröffentlicht und verschiebe Funktionen wie das verarbeiten von Likes, Boosts, Replies und so weiter auf eine ungewisse Zukunft. Außerdem will ich das Plugin so bauen, dass der ActivityPub-Core soweit losgelöst ist und die Anbindung an Kirby nur eine Art Adapter ist. Theoretisch könnte man den Core nochmal in ein eigenes Composer-Package herausziehen, aber so viel Bock habe ich auch nicht.

Tröt, tröt

Kommen wir zum Erstellen eines Tröts, wenn ein neuer Blogpost veröffentlich wird! Wenn man den Rest hat, ist das, wie immer, gar nicht so das Problem, allerdings gibt es auch hier wieder ein paar kleine Fallstricke.

Eine Activity, die man nun auf den Weg schickt, sieht erstmal ungefähr so aus:

{
    "@context": "https://www.w3.org/ns/activitystreams",

    "id": "https://knuspermagier.de/_aplite/activities/1",
    "type": "Create",
    "actor": "https://knuspermagier.de/_aplite/user/knuspermagier",

    "object": {
        "id": "https://knuspermagier.de/_aplite/posts/1",
        "type": "Note",
        "published": "2022-11-20T10:10:10Z",
        "attributedTo": "https://knuspermagier.de/_aplite/user/knuspermagier",
        "content": "<p>Hello world</p>",
        "to": ["https://www.w3.org/ns/activitystreams#Public"]
    }
}

Wie man sieht ist das Object, ein Element vom Type Note, in eine Activity vom Typ Create gewrappt. Beides, also die Activity und das Note-Objekt haben ihre eigene eindeutige id.

Hat man dieses Objekt erstellt, dann schickt man es… ja wohin eigentlich? Genau, an alle Follower, die man hat. Spätestens hier fällt einem ein, dass man einen Storage-Layer braucht um sich ein paar Sachen zu seinen Followern zu merken. Ich gab der Core-Library also ein Storage-Interface und legte im Kirby-Teil ein KirbyFilesystemStorage an. Da ich wirklich keine Lust hatte, SQL-Queries zu schreiben oder Eloquent einzubinden, muss es erstmal das Dateisystem richten.

Hat man nun die Handles aller Follower gespeichert, ist es ein Leichtes für jeden die URL des inbox-Endpunkts herauszufinden und dort den (korrekt signierten) Request hinzuschicken. Im Idealfall wird der mit einem 202 Created-Status quittiert. Da freut man sich direkt einmal, man hat es geschafft!

Leider ist es auch hier wieder nicht so einfach. Mastodon verarbeitet alle Eingänge in die inbox in einer Queue uns sagt erstmal “Jaja, alles töffte!”. Falls bei der Verarbeitung ein Fehler auftritt, merkt man das erst, wenn in der Timeline nichts auftaucht. Schade. Hier habe ich auch wieder einige Zeit zugebracht um in den Mastodon-Logs auf die Suche nach dem Fehler zu gehen. Ich weiß leider nicht mehr genau, wo ich hier eine Hürde hatte, war aber wahrscheinlich mal wieder ein klitzekleiner Quatschfehler.


Numerische IDs

Eine Eigenheit, der ich noch nicht auf die Spur kam, ist die Tatsache, dass Mastodon alle Activities verwirft, deren id nicht numerisch endet. Ich hab den Code kurz durchforstet, aber nichts gefunden. Ich bin fast verrückt geworden, als ich mal wieder einen Fehler hatte mir nicht erklären konnte, woran es liegt. Ich wollte nämlich die URL eines Posts als ID benutzen -- es gibt erst, als ich sowas wie ?i=12345 dran hing. Sehr komisch.


Der/die aufmerksame Leser:in wird sich nun vielleicht wundern. Ein Request für jeden Follower? Artet das nicht aus? Ganz so schlimm es es am Ende nicht, ActivityPub hat auch das Konzept einer Shared Inbox, und Mastodon implementiert das auch so.

Statt die Payload mit einem to: ['hans@example.com'] an https://example.com/user/hans/inbox zu schicken nutze ich also die geteilte Inbox und schicke die Payload mit einem cc: ['hans@example.com', 'henriette@example.com', …] an https://example.com/inbox.

Je nachdem, wie die Follower auf die verschiedenen Instanzen verteilt sind spart das schonmal einige Requests.

Natürlich müsste man hier, wenn man es richtig machen will, zum Versenden der Aktivitäten trotzdem mal eine Queue einbauen. Wenn ich gerade einen Post speichere, und er ins Fediverse gepusht wird, dauert das schon 4-5 Sekunden, und der Blog hat nur 15 Follower. Vor allem in dieser aktuellen Übergangszeit, wo einzelne Instanzen sehr langsam, oder auch mal down sind, ist das schon etwas wackelig.

Ach Mist, da war ja noch was mit den Signaturen

Eigentlich dachte ich nun, ich wäre fertig und könnte diesen Post schreiben und das Plugin raushauen, doch da ist natürlich noch eine Sache, die ich erstmal bei Seite gelegt hatte. Natürlich muss ich auf meiner Seite nicht nur für alle Requests, die ich verschicke, die Signaturen generieren — ich muss natürlich auch bei allem, was in meiner inbox landet die Signatur verifizieren.

Hier schlugen ein paar meiner Versuche, es nur so halb-vernünftig zu implementieren erstmal fehl. Meine Hoffnung, dass alle Instanzen sicher über die gleichen Sachen signieren, wie ich das mache, weil ich es so aus irgendeinem Tutorial zog, erwies sich nicht als war.

Statt :

(request-target) host date digest

sieht das bei einem Mastodon-Request nämlich so aus:

(request-target) host date digest content-type

Ja, okay, es ist nur der Content-Type-Header dazu gekommen, aber keine Ahnung, falls irgendein Fediverse-Teilnehmer die Dinger jetzt in einer ganz anderen Reihenfolge ver-signiert, oder was auch immer, bin ich jetzt auf der sicheren Seite dadurch, es nicht hard gecodet zu haben.

Das verifizieren einer Signatur funktioniert auf jeden Fall ganz ähnlich, wie das Erzeugen. Man erstellt seinen Plaintext nach dem vorgebenen Muster, nur dass man hier jetzt halt die Header aus dem Request benutzt, statt sich die Werte selber auszudenken.

Zusätzlich zum Plaintext braucht man noch den Public Key von dem User, der den Request in Auftrag gegeben hat. Den muss man natürlich noch aus dem entsprechenden User-Profil fischen, wo der steht, habe ich oben ja schonmal erwähnt.

Alles zusammen kippt man in openssl_verify und hofft, dass die Funktion 1 zurück gibt. Wenn nicht, muss man leider weinen, denn ein “Joa, die Signatur stimmt halt nicht” ist halt ein Fehler, der einem nicht sehr viel weiterhilft.

Neben dem eigentlichen verifizieren der Signatur sollte man eigentlich auch noch überprüfen, ob das Datum im Date-Header in einem zeitlich vertretbaren Rahmen liegt, um zu vermeiden, dass ein Angreifer sich ein Päckchen geschnappt hat um es einfach später nochmal zu schicken. Außerdem speise ich gerade einfach den Digest-Header, der mitgeschickt wird wieder in die Signatur ein, was natürlich Quatsch ist. Ich auf der Empfängseite muss den Digest vom übermittelten Payload natürlich selber berechnen diesen Wert benutzen. Ich fixe das auch gleich, aber ich wollte erstmal diesen Blogpost fertig schreiben!

Ausblick

So, also wir können diesem Blog folgen, der Blog kann Aktivitäten veröffentlichen, was fehlt noch, und habe ich überhaupt Lust daran weiter zu arbeiten?

Folgende Sachen würden mir spontan einfallen:

Ob ich das mache, oder ob mich die Lust nach der Veröffentlichung dieses Blogposts sofort verlassen wird, weiß ich noch nicht. Das mit den Likes und Boosts ist zwar verlockend, aber ich habe das Like-Feature in diesem Blog nicht ohne Grund vor Ewigkeiten abgeschaltet, ich hab keine Lust ständig gucken zu müssen, ob jemand einen Button gedrückt hat.

Mit den Replies geht mal wieder die DSGVO einher… möchte ich hier Content von anderen im Blog anzeigen? Abgesehen davon, dass es natürlich erst nach einer Moderation angezeigt werden dürfte. Ist das schreiben einer @-Reply eine implizite Zustimmung auf einer ganz anderen Webseite angezeigt zu werden? Ich will mich damit nicht auseinandersetzen.

Bis hierhin war es eine ganz spaßige Exploration.

Offene Fragen

Eine offene Frage bleibt mir noch. Warum zeigen alle Mastodon-Instanzen nur Followings und Follower eines Users an, die von der eigenen Instanz kommen?

Also klar, die Instanz foo.social weiß jetzt nix davon, wenn dem User hase@hoppel.social der User fuchs@wald.social folgt, das ist mir klar, woher soll foo.social das wissen. Jedoch gibt es ja die https://hoppel.social/users/hase/followers-Schnittstelle, die, soweit ich das sehe, sogar aufgerufen wird um die Anzahl der Follower korrekt darzustellen. Warum wurde sich dagegen entschieden einfach den Inhalt dieser Liste zu laden und darzustellen?

Das einzige, was mir einfallen würde, ist die Tatsache, dass es natürlich irgendeine Bastler-Instanz sein könnte, auf der hase@hoppel.social unterwegs ist, die so zurechtgebastelt ist, dass in der Antwort des /follower-Endpunkts alles mögliche drin steht, und nicht nur wirkliche, bestätigte Follower. barack@obama.social, oder so.

(Das gleiche Frage ich mich auch bei den Posts. Eine Mastodon-Instanz zeigt keine Posts an, die ihr nicht per Create geschickt wurden, obwohl sie alle in der /outbox des Users liegen, nur ein Request entfernt)

Sicher gibt es irgendein Github-Issue, in dem das besprochen wird, muss ich vielleicht mal suchen.

Kommentare, Feedback und andere Anmerkungen? Schreib mir eine E-Mail 🤓