В последнее время, на фоне покупки 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 и другого аналогичного ПО, он должен поддерживать следующее:

  1. Отдавать объект актора с минимальным набором полей: ID, inbox, публичный ключ, юзернейм, тип Person.
  2. Отвечать на webfinger-запросы с эндпоинта /.well-known/webfinger. Mastodon без этого откажется видеть вашего актора.
  3. Рассылать свои корректно подписанные активити подписчикам и кому угодно ещё, кому они могут быть актуальны — например, пользователям, упомянутым в посте.
  4. Принимать POST-запросы в inbox и проверять их подписи. Для начала хватит поддержки 4 типов активити: Follow, Undo{Follow}, Accept{Follow} и Create{Note}.
  5. При получении корректной активити Follow, сохранить информацию о новом подписчике куда-нибудь на диск, отправить ему Accept{Follow} и впоследствии отправлять ему все активити о, например, создании новых постов.
  6. А при получении Undo{Follow} — удалить его из этого списка (отправлять ничего не нужно).
  7. Крайне желательно, но не строго обязательно, иметь урлы для уже созданных постов, по которым отдаётся объект 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<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: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<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 для клиентских приложений
  • Добавить какую-нибудь аутентификацию, в конце концов
  • Кэшировать объекты с других серверов локально, чтобы не делать слишком много одинаковых запросов


Полезные ссылки



Больше ActivityPub-серверов, хороших и разных:

Комментарии (19)


  1. Enfriz
    05.11.2023 15:08
    +1

    А есть какие-нибудь готовые библиотеки для популярных языков, чтобы не заниматься самому подсчётом подписей, ответами на webfinger итд? Хочу получать Активити в виде высокоуровневых событий на любимом языке программирования и дальше уже что-то с ними делать.


    1. grishkaa Автор
      05.11.2023 15:08
      +2

      Есть — как минимум для php, для js и для go. Для C#, вроде, ничего нет, но ты можешь исправить эту ситуацию :)


      1. skobkin
        05.11.2023 15:08

        Если мне не изменяет память, для PHP их две: одна завязана на конкретную БД без каких-либо абстракций и интерфейсов, а другая странноватая в целом.

        Есть еще реализация от автора Pixelfed, но он не выделял ее в библиотеку.


    1. Nasreddin_Hodja
      05.11.2023 15:08

      Реализаций полно, в т.ч. и на питоне, и даже на C вроде есть, но это всё на практике оказалось не самое трудоёмкое. Приходилось ещё разбираться почему с мастодоном одно работает, а с френдикой или пиксельфедом нет и т.п.


  1. akurilov
    05.11.2023 15:08

    Как относится ActivityPub к протоколу WebSub? Это одно и тоже или разные вещи?


    1. grishkaa Автор
      05.11.2023 15:08
      +1

      Разные. Общее только то, что и то, и другое - подписка на рассылку обновлений.


      1. akurilov
        05.11.2023 15:08

        Хм. Оба вроде используют JSON-LD формат. Что нужно выбрать для максимального охвата происходящих в интернетах событий тогда? Я планирую прикрутить это к Awakari


        1. grishkaa Автор
          05.11.2023 15:08

          А что понимается под "происходящими в интернете событиями"? ActivityPub — это на 99% про социальные штуки всякие, тот же мастодон. Существуют мосты в другие протоколы, но именно чтобы нативная поддержка — это именно соцсети. Ну и вордпресс, с недавнего времени, если владелец сайта включил.

          WebSub для меня существует сугубо теоретически, использования его на практике я (пока?) не видел.


          1. akurilov
            05.11.2023 15:08

            Мне нужна какая нибудь замена RSS с поддержкой push, например, на вебхук. Чтобы можно было получать уведомления о происходящем в популярных соцсетях на определённые темы


            1. grishkaa Автор
              05.11.2023 15:08

              Ну это явно не ActivityPub, наверняка существуют сервисы, которые так умеют. Но вряд ли бесплатно. Каким-нибудь маркетолухам и прочим сммщикам такое бывает нужно, а это же всё Серьёзный Бизнес™.


              1. akurilov
                05.11.2023 15:08

                Почему же не actvitypub? Мало используется в популярных сервисах?


                1. grishkaa Автор
                  05.11.2023 15:08

                  В популярных централизованных сервисах вообще не используется.


  1. Nasreddin_Hodja
    05.11.2023 15:08
    +3

    Круто! Вот бы и на Хабре появилась поддержка ActivityPub!


  1. JohnDidact
    05.11.2023 15:08

    Я как-то хотел свою реализацию написать, но стал спотыкаться на подписях заголовков... Мастодон посылал меня с такими подписями. После чего я решил отложить это дело на лучшие времена и остался на френдике, которая сейчас какого-то фига (лично у меня, а возможно и вовсе вопросы хостеру нужно задавать, а не воркеру) активити не отправляет.

    Ну а статья полезная, оставлю в закладках ????


    1. grishkaa Автор
      05.11.2023 15:08

      Можете потестить свои подписи с моим сервером Smithereen, он отвечает подробными сообщениями об ошибках в таких случаях.


  1. guvernir4
    05.11.2023 15:08

    Непонятно, а как собственно писать свои посты или туты, как принято их называть в мастодоне, и сохранять у себя в профиле, чтоб другие могли прийти и почитать, о чём я пишу?


    1. grishkaa Автор
      05.11.2023 15:08

      Писать — это как раз Create{Note}, который описан в статье. Сохранять в профиле — это outbox. Можете в мастодоне посмотреть, это коллекция с пагинацией со всеми вашими исходящими активити. Сам мастодон, правда, её с других серверов не прогружает.


      1. Nasreddin_Hodja
        05.11.2023 15:08

        Кстати, а есть кто прогружает? А то я у себя вроде и реализовал, но так и не тестил, судя по логам сервера ни outbox, ни реплаи к комментам спросом не пользуются другими инстансами, кроме каких-то ботов туда вроде никто не обращается.


        1. grishkaa Автор
          05.11.2023 15:08

          Outbox, кажется, никто, а реплаи я прогружаю в Smithereen при репосте или при открытии поста по ссылке (и меня бесит, что у некоторых этой коллекции нет)