Пишем минимальный 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 для преобразования человекочитаемых юзернеймов вида @vasya@example.social
в настоящие идентификаторы акторов вида 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-ключ, с помощью которго проверяется подпись активити от этого актора:id
— идентификатор ключа. В теории их может быть несколько, но на практике у всех один. Просто добавляете#main-key
после ID актора.owner
— идентификатор владельца ключа, просто ID вашего актора.publicKeyPem
— сам ключ в формате PEM.
Дополнительные необязательные поля, которые вы можете захотеть добавить:
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 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 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:gargron@mastodon.social
...
< HTTP/2 200
< content-type: application/jrd+json; charset=utf-8
...
{
"subject":"acct:Gargron@mastodon.social",
"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 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": "Привет, федивёрс
"
}
}
Если вы всё сделали правильно, под постом появится ваш комментарий.
Здесь: 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 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 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 для клиентских приложений
- Добавить какую-нибудь аутентификацию, в конце концов
- Кэшировать объекты с других серверов локально, чтобы не делать слишком много одинаковых запросов
Полезные ссылки
Больше ActivityPub-серверов, хороших и разных: