Пишем минимальный ActivityPub-сервер с нуля
В последнее время, на фоне покупки Twitter Илоном Маском, люди начали искать ему альтернативы — и многие нашли такую альтернативу в Mastodon.
Mastodon — это децентрализованная социальная сеть, работающая по модели федерации, как email. Протокол федерации называется ActivityPub и является стандартом W3C, а Mastodon — далеко не единственная его реализация, но самая популярная. Различные реализации протокола, как правило, совместимы друг с другом, настолько, насколько им позволяют их совпадения в функциональности. У меня есть и мой собственный проект ActivityPub-сервера — Smithereen, такой зелёный децентрализованный ВК, где я когда-нибудь таки верну стену.
В этой статье мы рассмотрим основы протокола ActivityPub и напишем минимально возможную реализацию сервера, позволяющую отправлять посты в сеть («fediverse»), подписываться на других пользователей и получать от них обновления.
Что вообще такое ActivityPub?
Вообще, согласно спецификации, ActivityPub бывает двух видов: межсерверный и клиент-серверный. Клиент-серверная разновидность странная и не особенно юзабельная на неидеальном интернете, да и вообще её никто толком не реализует, так что мы её рассматривать не будем.
ActivityPub применительно к федерации состоит из следующих основных частей:
- Акторы — объекты (или субъекты?), которые могут совершать какие-то действия. Например, пользователи или группы. Однозначно глобально идентифицируются адресом (URL), по которому лежит JSON-объект актора.
- Активити — объекты, представляющие собой эти самые действия, вида «Вася опубликовал пост».
- Инбокс — эндпоинт на сервере, в который эти активити отправляются. Его адрес указывается в поле
inbox
у актора.
Все объекты — это просто JSON с определённой схемой. На самом деле это слегка проклятый JSON-LD с неймспейсами, но для наших нужд на них можно забить. Объекты ActivityPub имеют mime-тип application/ld+json; profile="https://www.w3.org/ns/activitystreams"
или application/activity+json
.
Работает это всё так: актор отправляет активити другому актору в инбокс, а тот её принимает, проверяет, и что-нибудь с ней делает. Например, кладёт новый пост в свою БД, или создаёт подписку и отправляет в ответ активити «подписка принята». Сами активити отправляются post-запросами с подписью ключом актора (у каждого актора есть пара RSA-ключей для аутентификации).
В дополнение к этому необходимо реализовать протокол WebFinger для преобразования человекочитаемых юзернеймов вида @[email protected]
в настоящие идентификаторы акторов вида https://example.social/users/vasya
. Mastodon отказывается работать с серверами, которые это не реализуют, даже если ему дать прямую ссылку на актора ¯\_(ツ)_/¯
Что должен уметь сервер для участия в федивёрсе?
Для того, чтобы с вашим сервером можно было полноценно взаимодействовать из Mastodon и другого аналогичного ПО, он должен поддерживать следующее:
- Отдавать объект актора с минимальным набором полей: ID, inbox, публичный ключ, юзернейм, тип Person.
- Отвечать на webfinger-запросы с эндпоинта
/.well-known/webfinger
. Mastodon без этого откажется видеть вашего актора. - Рассылать свои корректно подписанные активити подписчикам и кому угодно ещё, кому они могут быть актуальны — например, пользователям, упомянутым в посте.
- Принимать POST-запросы в inbox и проверять их подписи. Для начала хватит поддержки 4 типов активити: Follow, Undo{Follow}, Accept{Follow} и Create{Note}.
- При получении корректной активити Follow, сохранить информацию о новом подписчике куда-нибудь на диск, отправить ему Accept{Follow} и впоследствии отправлять ему все активити о, например, создании новых постов.
- А при получении Undo{Follow} — удалить его из этого списка (отправлять ничего не нужно).
- Крайне желательно, но не строго обязательно, иметь урлы для уже созданных постов, по которым отдаётся объект Note, чтобы их можно было прогрузить на другом сервере, вставив адрес в поле поиска.
Практическая часть
Теперь давайте рассмотрим каждый пункт в подробностях и с примерами кода. Моя реализация будет на Java, поскольку это мой родной язык программирования. Вы можете либо тыкать в мой пример, либо написать сами по образу и подобию на вашем предпочитаемом стеке. Вам также понадобится домен и HTTPS-сервер/прокси — либо какой-нибудь ngrok, либо ваш собственный.
У моего примера две зависимости: микро-фреймворк Spark для приёма входящих запросов и Gson для работы с JSON. Весь код целиком доступен у меня на гитхабе.
Перед тем, как начинать, сгенерируйте пару RSA-ключей и положите их в папку проекта:
openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -out private.pem openssl rsa -in private.pem -outform PEM -pubout -out public.pem
Отдаём объект актора
По любому удобному вам адресу отдаёте JSON-объект такого вида:
{ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "type": "Person", "id": "https://example.social/users/vasya", "preferredUsername": "vasya", "inbox": "https://example.social/inbox", "publicKey": { "id": "https://example.social/users/vasya#main-key", "owner": "https://example.social/users/vasya", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n----END PUBLIC KEY----" } }
Отдавать нужно с заголовком Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
. Назначение полей:
@context
— контекст JSON-LD. Не обращайте внимание на него, просто помните, что он должен быть. Но если любопытно узнать про него побольше, то вот и вот.type
— тип объекта. Person — личный профиль человека. Бывает ещё Group, Organization, Application и Service.id
— глобальный идентификатор объекта, а также адрес, по которому его можно получить (ссылка на самого себя, ага).preferredUsername
— юзернейм пользователя, который выводится в интерфейсе и используется для поиска и упоминаний.inbox
— адрес того самого инбокса, эндпоинта, принимающего входящие активити.publicKey
— публичный RSA-ключ, с помощью которго проверяется подпись активити от этого актора:
Дополнительные необязательные поля, которые вы можете захотеть добавить:
followers
иfollowing
— адреса коллекций подписчиков и подписок. Можно возвращать оттуда 403 или 404, но некоторым серверам важно, чтобы эти поля просто присутствовали в объекте.outbox
— inbox в обратную сторону, коллекция некоторых активити, отправленных этим пользователем. Обычно там только Create{Note} и Announce{Note}.url
— ссылка на профиль в веб-интерфейсе сервера.name
— отображаемое имя, например, Вася Пупкин.icon
иimage
— аватарка и обложка соответственно. Объекты типа Image, с полямиtype
,mediaType
иurl
. Аватарки обычно квадратные.summary
— поле «о себе». В нём обычно HTML.
Чтобы посмотреть на объект актора (или поста, или ещё чего-нибудь) с другого сервера, отправьте GET-запрос с заголовком Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
на тот же адрес, по которому вы видите профиль в браузере:
$ curl -H 'Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"' https://mastodon.social/@Gargron {"@context":["https://www.w3.org/ns/activitystreams", ...
private static final String AP_CONTENT_TYPE = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; /** * Получить объект актора с другого сервера * @param id идентификатор актора * @throws IOException в случае ошибки сети */ private static JsonObject fetchRemoteActor(URI id) throws IOException { try { HttpRequest req = HttpRequest.newBuilder() .GET() .uri(id) .header("Accept", AP_CONTENT_TYPE) .build(); HttpResponse<String> resp = HTTP_CLIENT.send(req, HttpResponse.BodyHandlers.ofString()); return JsonParser.parseString(resp.body()).getAsJsonObject(); } catch(InterruptedException x) { throw new RuntimeException(x); } }
private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); get("/actor", (req, res) -> { Map<String, Object> actorObj = Map.of( "@context", List.of("https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"), "type", "Person", "id", ACTOR_ID, "preferredUsername", USERNAME, "inbox", "https://" + LOCAL_DOMAIN + "/inbox", "publicKey", Map.of( "id", ACTOR_ID + "#main-key", "owner", ACTOR_ID, "publicKeyPem", publicKey ) ); res.type(AP_CONTENT_TYPE); return GSON.toJson(actorObj); });
Отвечаем на webfinger-запросы
Запрос и (урезанный до минимально необходимого) ответ выглядят вот так:
$ curl -v https://mastodon.social/.well-known/webfinger?resource=acct:[email protected] ... < HTTP/2 200 < content-type: application/jrd+json; charset=utf-8 ...
{ "subject":"acct:[email protected]", "links":[ { "rel":"self", "type":"application/activity+json", "href":"https://mastodon.social/users/Gargron" } ] }
Это всего лишь способ сказать «ID актора, соответствующий юзернейму gargron на сервере mastodon.social — https://mastodon.social/users/Gargron
».
Этих двух эндпоинтов уже достаточно, чтобы ваш актор был виден на других серверах — попробуйте ввести URL объекта актора в поле поиска в Mastodon, чтобы увидеть его профиль.
Рассылаем активити
Активити отправляются POST-запросами в инбоксы. Для аутентификации используются HTTP-подписи — это такой заголовок с подписью других заголовков с помощью ключа актора. Выглядит он вот так:
Signature: keyId="https://example.social/actor#main-key",headers="(request-target) host date digest",signature="..."
Где keyId
— идентификатор ключа из объекта актора, headers
— заголовки, которые мы подписали, а signature
— сама подпись в base64. В подписанные заголовки обязательно входит Host
, Date
и «псевдо-заголовок» (request-target)
— это метод и путь (например, post /inbox
). Время в Date
должно отличаться от времени принимающего сервера не более чем на 30 секунд — это нужно для предотвращения replay-атак. Современные версии Mastodon также требуют заголовок Digest
, это SHA-256 в base64 от тела запроса:
Digest: SHA-256=n4hdMVUMmFN+frCs+jJiRUSB7nVuJ75uVZbr0O0THvc=
Строка, которую нужно подписать — это названия и значения заголовков в том же порядке, в котором они перечислены в поле headers. Названия пишутся строчными буквами и отделяются от значений двоеточием и пробелом. После каждого заголовка, кроме последнего, ставится перевод строки (\n
):
(request-target): post /users/1/inbox host: friends.grishka.me date: Sun, 05 Nov 2023 01:23:45 GMT digest: SHA-256=n4hdMVUMmFN+frCs+jJiRUSB7nVuJ75uVZbr0O0THvc=
/** * Отправить активити в чей-нибудь инбокс * @param activityJson JSON самой активити * @param inbox адрес инбокса * @param key приватный ключ для подписи * @throws IOException в случае ошибки сети */ private static void deliverOneActivity(String activityJson, URI inbox, PrivateKey key) throws IOException { try { byte[] body = activityJson.getBytes(StandardCharsets.UTF_8); String date = DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.now().atZone(ZoneId.of("GMT"))); String digest = "SHA-256="+Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(body)); String toSign = "(request-target): post " + inbox.getRawPath() + "\nhost: " + inbox.getHost() + "\ndate: " + date + "\ndigest: " + digest; Signature sig = Signature.getInstance("SHA256withRSA"); sig.initSign(key); sig.update(toSign.getBytes(StandardCharsets.UTF_8)); byte[] signature = sig.sign(); HttpRequest req = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofByteArray(body)) .uri(inbox) .header("Date", date) .header("Digest", digest) .header("Signature", "keyId=\""+ACTOR_ID+"#main-key\",headers=\"(request-target) host date digest\",signature=\""+Base64.getEncoder().encodeToString(signature)+"\",algorithm=\"rsa-sha256\"") .header("Content-Type", AP_CONTENT_TYPE) .build(); HttpResponse<String> resp = HTTP_CLIENT.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(resp); } catch(InterruptedException | NoSuchAlgorithmException | InvalidKeyException | SignatureException x) { throw new RuntimeException(x); } }
У вас всё готово для того, чтобы отправить свою первую активити! Попробуйте оставить комментарий под моим постом об этой статье — отправьте вот это в мой инбокс, https://friends.grishka.me/users/1/inbox
, заменив example.social на домен, на котором запущен ваш сервер:
{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://example.social/createHelloWorldPost", "type": "Create", "actor": "https://example.social/actor", "to": "https://www.w3.org/ns/activitystreams#Public", "object": { "id": "https://example.social/helloWorldPost", "type": "Note", "published": "2023-11-05T12:00:00Z", "attributedTo": "https://example.social/actor", "to": "https://www.w3.org/ns/activitystreams#Public", "inReplyTo": "https://friends.grishka.me/posts/884435", "content": "<p>Привет, федивёрс</p>" } }
Если вы всё сделали правильно, под постом появится ваш комментарий.
Здесь: Create
— тип активити, мы что-то создали. actor
— кто создал, object
— что он создал, to
— кому эта активити адресована (всему миру). А создали мы «заметку» (так в ActivityPub называются посты) с текстом «Привет, федивёрс», которая является ответом на мой пост. В тексте постов поддерживается базовый набор HTML-тегов для форматирования, но конкретный список того, что поддерживается, зависит от конкретного сервера.
Принимаем активити и проверяем подписи
Мы только что отправили активити, теперь нам нужно научиться их принимать. Повторяться смысла нет, всё то же самое. Для проверки подписи нужно:
- Распарсить заголовок Date. Если время отличается от текущего больше, чем на 30 секунд, отклонить запрос (можно вернуть код 400, например).
- Распарсить тело запроса. Получить объект актора по урлу в
actor
. - Проверить, что
keyId
в заголовкеSignature
совпадает с идентификатором ключа актора. - Распарсить публичный ключ, составить строку для подписи (см. выше) и проверить подпись.
Код для получения активити с проверкой подписи
post("/inbox", (req, res) -> { // Время в заголовке Date должно быть в пределах 30 секунд от текущего long timestamp = DateTimeFormatter.RFC_1123_DATE_TIME.parse(req.headers("Date"), Instant::from).getEpochSecond(); if (Math.abs(timestamp - Instant.now().getEpochSecond()) > 30) { res.status(400); return ""; } // Вытаскиваем актора JsonObject activity = JsonParser.parseString(req.body()).getAsJsonObject(); URI actorID = new URI(activity.get("actor").getAsString()); JsonObject actor = fetchRemoteActor(actorID); // Парсим заголовок и проверяем подпись Map<String, String> signatureHeader = Arrays.stream(req.headers("Signature").split(",")) .map(part->part.split("=", 2)) .collect(Collectors.toMap(keyValue->keyValue[0], keyValue->keyValue[1].replaceAll("\"", ""))); if (!Objects.equals(actor.getAsJsonObject("publicKey").get("id").getAsString(), signatureHeader.get("keyId"))) { // ID ключа, которым подписан запрос, не совпадает с ключом актора res.status(400); return ""; } List<String> signedHeaders = List.of(signatureHeader.get("headers").split(" ")); if (!new HashSet<>(signedHeaders).containsAll(Set.of("(request-target)", "host", "date"))) { // Один или несколько обязательных для подписи заголовков не содержатся в подписи res.status(400); return ""; } String toSign = signedHeaders.stream() .map(header -> { String value; if ("(request-target)".equals(header)) { value="post /inbox"; } else { value=req.headers(header); } return header+": "+value; }) .collect(Collectors.joining("\n")); PublicKey actorKey = Utils.decodePublicKey(actor.getAsJsonObject("publicKey").get("publicKeyPem").getAsString()); Signature sig = Signature.getInstance("SHA256withRSA"); sig.initVerify(actorKey); sig.update(toSign.getBytes(StandardCharsets.UTF_8)); if (!sig.verify(Base64.getDecoder().decode(signatureHeader.get("signature")))) { // Подпись не проверилась res.status(400); return ""; } // Всё получилось - запоминаем активити, чтобы потом показать её пользователю receivedActivities.addFirst(activity); return ""; // Достаточно просто ответа с кодом 200 });
Подписываемся на людей
Теперь у вас есть все компоненты, необходимые для того, чтобы подписаться на другого пользователя. Попробуйте подписаться на свой аккаунт в Mastodon, или, например, на mastodon.social/@Mastodon. Отправьте такую активити нужному актору (не забудьте заменить example.social
на свой домен и object
на id желаемого актора):
{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://example.social/oh-wow-i-followed-someone", "type": "Follow", "actor": "https://example.social/actor", "object": "https://mastodon.social/users/Mastodon" }
Вскоре после этого вам должна придти активити Accept
, с вашей Follow
внутри в качестве object
(я типы таких вложенных активити пишу в формате Accept{Follow}
). Это означает, что другой сервер принял вашу подписку, и будет впредь присылать вам, например, Create
, Announce
и Delete
про посты, которые этот актор будет создавать, репостить и удалять. Чтобы отписаться, отправьте Undo{Follow}
.
Что дальше?
Если вы дочитали досюда и всё сделали по инструкции, поздравляю — у вас есть работающий ActivityPub-сервер! Можете попробовать очевидные улучшения:
- Добавить возможность подписываться на вашего актора
- Не просто складывать активити в массив, а обрабатывать их в зависимости от типа
- Да и в интерфейсе можно сделать не большое поле для JSON, а нормальные кнопки для конкретных действий
- Подключить базу данных для хранения пользователей, постов и подписок
- … и поддержать больше одного пользователя на сервере
- Сделать полноценный веб-интерфейс и/или API для клиентских приложений
- Добавить какую-нибудь аутентификацию, в конце концов
- Кэшировать объекты с других серверов локально, чтобы не делать слишком много одинаковых запросов
Полезные ссылки
- Код к этой статье
- Аналогичные статьи от основателя Mastodon — раз и два
- Спецификации: ActivityPub, ActivityStreams, Activity Vocabulary
- FediDB — каталог серверов и статистика по федивёрсу
- Как читать спецификацию ActivityPub
- Серия статей об ActivityPub от Ted Unangst
- Форум SocialHub. На нём обсуждают ActivityPub и всякие смежные темы
- Что и как Mastodon поддерживает из ActivityPub
Больше ActivityPub-серверов, хороших и разных:
- Собственно, Mastodon
- Smithereen — мой ВК с федерацией
- Pixelfed — такой федеративный фотошеринг
- Pleroma — ещё один дивжок для микроблоггинга
- Misskey — и ещё один, от няпонцев. Со встроенными кошкодевочками
- Lemmy — агрегатор ссылок по типу реддита
- Friendica — тоже что-то похожее по смыслу на ВК
- Honk — минималистичный однопользовательский микроблог