ActivityPub の実装が難しい理由、そしてその必要がない理由
(hackers.pub)- ActivityPub サーバーを自作すると、最初の
Followリクエストから説明のない401 Unauthorizedに阻まれがちで、Fedify は 署名・JSON-LD・配信・セキュリティ の負担をアプリケーションコードの外へ移す TypeScript フレームワークである - フェディバース認証では、失効した草案
draft-cavage-http-signatures-12と標準RFC 9421が併用されており、文書署名まで含めると 4種類の署名メカニズム と RSA・Ed25519 鍵を扱う必要がある - 同じ ActivityPub アクティビティでも、JSON-LD では文字列、配列、インラインオブジェクト、URI 参照など複数の形で届くため、直接実装するほど 防御コード がコードベース全体に広がる
- 分散配信では、
DeleteがCreateより先に届く「ゾンビ投稿」のような問題が生じ、キュー・リトライ・冪等性・順序保証・サーキットブレーカーが必要になる - Fedify は 13 の Web フレームワーク統合、KV・メッセージキューアダプター、CLI・リンター・デバッガー・OpenTelemetry を提供し、ActivityPub の細かな知識がなくても 連合アプリ開発を始められるようにする
ActivityPub を直接実装するときにぶつかる問題
- 最初の
Followアクティビティを Mastodon に送るには、JSON の作成、HTTP リクエスト署名、POSTまで処理する必要があるが、失敗すると401 Unauthorizedの一行だけが返ってくることがある- 原因は
Dateヘッダーの時計ずれ、Digestハッシュの誤り、(request-target)の大文字小文字、公開鍵の表現方式などである可能性がある - リモートサーバーが理由を教えてくれなければ、他のサーバー実装のコードを読みながらデバッグするしかない
- 原因は
- Fedify は、単一ユーザーのマイクロブログサーバー Hollo を作る過程から始まった
- ActivityPub 実装の負担が製品開発を飲み込んだため、アプリより先に必要なフレームワークとして作られた
- 難しさは主に 署名標準、JSON-LD 文書の形、分散配信、実装ごとの慣習、基本的なセキュリティ設定に集中している
署名標準が一つではない
- サーバー間認証には HTTP 署名を使うが、実際のフェディバースでは、失効した草案 draft-cavage-http-signatures-12 と標準 RFC 9421 が併存している
- どのサーバーがどの署名を受け付けるかは試すまで分からないため、ある方式で署名して拒否されたら別方式で再署名し、成功した方式をサーバーごとに記憶しておく必要がある
- この手順は double-knocking と呼ばれる
- HTTP 署名はリクエスト送信者しか証明しないため、受け取ったアクティビティを第三者へ転送する inbox forwarding のような場面では、文書自体に付く署名も必要になる
- 必要なメカニズムは Linked Data Signatures と Object Integrity Proofs を含めて合計 4 種類である
- 鍵は RSA と Ed25519 の 2 種類を併せて管理しなければならない
JSON-LD 文書の形が変わり続ける
- ActivityPub の転送形式は JSON-LD であり、同じ意味の
Createアクティビティでも複数の形で表現されうるactorは URI 文字列の場合もあれば、インラインのPersonオブジェクトの場合もあるtoは文字列 1 つの場合も、配列の場合もあるobjectはインラインオブジェクトにも URI にもなりうる
- 公開対象を表すアドレスも
https://www.w3.org/ns/activitystreams#Public、as:Public、Publicの 3 つの表現がすべて有効である - 仕様どおりに処理するには、JSON-LD プロセッサーで expansion を行った後、compaction して正規化する必要がある
- 多くの実装はこれを「ただの JSON」のように扱い、特定サーバーが出力した形式で静かに壊れる
- 直接実装すると、値が文字列か、配列か、オブジェクトか、取得すべき URI かを確認する 防御コード があちこちに生まれる
分散配信と「ゾンビ投稿」
- ユーザーが投稿した直後に誤字を見つけて削除すると、サーバーは
Createの次にDeleteを送るが、ネットワーク状況によっては受信サーバーがDeleteを先に受け取ることがある- まだ存在しない投稿の削除を無視した後で
Createを処理すると、投稿者は削除済みだと信じている投稿がそのサーバーに残り続ける
- まだ存在しない投稿の削除を無視した後で
- フォロワーが 5000 人いれば、投稿 1 件で数千件の HTTP 配信が発生し、リクエストハンドラー内で処理すると投稿ボタンへの応答が遅くなったり、サーバーが耐えられなくなったりする
- キューを使うとしても、失敗した配信の再試行スケジュール、指数バックオフ、再試行回数、
500 Internal Server Errorと410 Goneの違い、消えたサーバーのフォロワー整理、長期障害ホストの扱いまで決めなければならない - この領域は単なるプロトコル実装より 分散システムエンジニアリング に近い
仕様だけでは相互運用は終わらない
- 仕様を完璧に守っても、実際のフェディバース実装との相互運用問題は残る
- Mastodon の secure mode は、
GETリクエストにも HTTP 署名を要求する authorized fetch を使う- 両方のサーバーが secure mode だと、相手の公開鍵を取得するには署名が必要で、署名を検証するには相手が先に自分の公開鍵を取得していなければならないというデッドロックが生じる
- コミュニティはサーバー自体を表す instance actor で署名して回避しているが、これは仕様にはない
- Threads は
actorがインラインオブジェクトとして入ったアクティビティをパースできないため、Threads に送るときはactorを URI として送る必要がある - Lemmy は、Mastodon が要求しない
Groupactor フィールドがないと黙って拒否する- 例としては、
attributedToでつながる moderators collection とfeaturedcollection がある
- 例としては、
- Misskey は独自の語彙拡張を持っており、quote post だけでも実装ごとに 3 つのプロパティ名が使われる
- 相互運用は一度合わせて終わる作業ではなく、継続して維持しなければならない領域である
直接実装の初期状態は安全ではない
- 受信したアクティビティの署名検証を省くと、誰でも偽の
FollowやDeleteを注入できる - ドキュメントローダーを制限しないと、悪意あるアクティビティが
http://169.254.169.254/や内部ネットワークを指し、サーバーを SSRF プロキシにしてしまう可能性がある - 埋め込みオブジェクトの出所検証を省略すると、どのサーバーでも特定の人物が話したように見える文書を出力できてしまう
- こうした落とし穴はすぐには目立たず、悪用されるまではすべてが正常に動いているように見えることがある
Fedifyが代わりに処理する領域
- Fedifyは、ActivityPub と関連標準を用いて連合サーバーアプリを構築するための TypeScript ライブラリ
- Deno、Node.js、Bun で動作し、Cloudflare Workers のような edge ランタイムもサポート
- 設計目標は、署名、JSON-LD、配送、実装ごとの差異、セキュリティの詳細をアプリケーションコードから取り除くこと
-
署名処理
- actor dispatcher と key pair dispatcher を登録すると、actor を 1 つフェディバースに公開できる
- 送信するすべてのリクエストには署名が付く
- RSA キーでは HTTP Signatures と Linked Data Signatures を生成する
- Ed25519 キーを追加すると Object Integrity Proofs も付与される
- 4 つのメカニズムが 1 つのアクティビティに共存し、受信側は自分が理解できる最も強力な方式で検証する
- Fedify は double-knocking を直接処理する
- 最初の接触は RFC 9421 で送信され、拒否されると draft-cavage で再試行する
- 成功した方式はサーバーごとにキャッシュされる
- 拒否レスポンスに
Accept-Signaturechallenge があれば、サーバーが要求したコンポーネントで再署名する
- 受信署名 はアプリケーションコードが見る前に検証され、検証に失敗したアクティビティはリスナーに到達しない
- actor dispatcher を登録するだけで WebFinger RFC 7033 サーバーも作成され、Mastodon の検索欄で
@alice@example.com形式で actor を見つけられる
-
JSON-LD の代わりに型を扱う
- Fedify は Activity Vocabulary 全体と主要ベンダー拡張をカバーする 約 80 のクラス を提供する
- クラスは型付きで不変であり、アクセサは JSON-LD が許容する文書形式の違いを吸収する
lookupObject()はハンドルを受け取り、WebFinger discovery まで含む完全な検索手順を実行するgetFollowers()のようなアクセサ は、値が URI 参照でもインラインオブジェクトでも同じように動作し、取得した値はキャッシュされる- ベンダーごとの差異も API の裏側に隠される
quoteUri、_misskey_quote、quoteUrlの 3 つの quote 属性は、新たに登場した FEP-044f のquoteとともに 1 つの API の裏に統合される- Misskey の
isCat属性も型として存在し、型安全に扱える
-
配送インフラと順序保証
createFederation()にメッセージキューを接続すると配送はバックグラウンドに移り、失敗時にはデフォルトで最大 10 回まで指数バックオフで自動再試行する- 投稿 1 件が数千のフォロワーに配送されるときは、two-stage fan-out が動作する
- 1 つの統合メッセージがキューに入る
- バックグラウンドワーカーがサーバーごとの配送ジョブに分割する
- 投稿ボタンは即座に応答する
- 再試行によって同じアクティビティが 2 回届くことがあるため、Fedify は処理済みアクティビティを 24 時間保持する 冪等性キャッシュ によって、重複をハンドラーの前でスキップする
sendActivity()呼び出しに{ orderingKey: post.id }を指定すると、同じ orderingKey を共有するアクティビティは、各受信サーバーに対して送信した順序どおりに配送されるDeleteがCreateを追い越すことはできない- 異なるキーのアクティビティは並列送信され、スループットを維持する
404 Not Foundや410 Goneでは再試行を停止し、登録された 恒久的配送失敗ハンドラー を呼び出す- shared inbox に送信した場合、その背後にあるフォロワー一覧も受け取って、消えたアカウントを整理できる
- 繰り返し失敗するホストに対しては、デフォルトで有効な サーキットブレーカー が配送を保留し、定期的に復旧を確認する
実装ごとの慣行とセキュリティのデフォルト
- Fedify は authorized fetch において、
.authorize()を dispatcher に接続し、検証済みのリクエスターの身元をコールバックに渡す- ブロックリストや非公開コレクションのような処理はアプリケーションロジックとして記述できる
- instance actor のデッドロック問題にも対応パターンがある
- Threads のインライン actor 問題は、デフォルトで有効な activity transformer が送信アクティビティ内のインライン actor を URI に変換して処理する
- Lemmy が要求する moderators collection は custom collection API で数行で公開でき、Lemmy の JSON-LD context はあらかじめ含まれている
- 新しい相互運用性の問題が見つかれば、修正は各アプリケーションではなく Fedify に入る
- セキュリティのデフォルトは安全側に寄っている
- 署名検証は有効化する機能ではなく、テスト用に無効化する機能である
- ドキュメントローダーは private address range と loopback をデフォルトでブロックし、DNS rebinding も考慮する
- SSRF にさらされるには、テスト用であることを示す名前のオプションを明示的に有効化しなければならない
- 埋め込みオブジェクトの出所が親ドキュメントと異なる場合、アクセサはそれを信頼せずオリジンから再取得する
- このオリジンベースのセキュリティモデルは FEP-fe34 に基づく
既存スタックと開発ツール
- Fedify は既存の Web スタックに合うように設計されており、13 の Web フレームワーク統合を提供する
- Express, Hono, Fastify, Koa, NestJS, Elysia
- Next.js, Nuxt, SvelteKit, Astro, SolidStart, Fresh
- ミドルウェアがコンテンツネゴシエーションを処理するため、同じ URL でブラウザには HTML を、Fediverse には JSON-LD を提供できる
- Fedify 自体のストレージは、単一のキー・バリューインターフェースだけを要求する
- Redis, PostgreSQL, MySQL/MariaDB, SQLite, Deno KV, Cloudflare Workers KV, インメモリアダプターがある
- メッセージキューは PostgreSQL, Redis, AMQP/RabbitMQ などを含む 8 種類が提供されており、合うものがなければインターフェースを自分で実装できる
- ドメインデータは既存のデータベースと ORM にそのまま置いておける
- ほかのライブラリですでに federation を運用している場合は、マイグレーションガイドとデータ移行スクリプトにより、activitypub-express などから既存のフォロワーを失わずに移行できる
- 上位パッケージも提供される
@fedify/relayは 1 回の関数呼び出しで完全な ActivityPub relay サーバーを提供する@fedify/backfillは Fediverse をたどって不完全な会話スレッドを復元する
-
開発ループのためのツール
fedify initは 1 行でプロジェクトをスキャフォールドするfedify tunnelはローカルサーバーを HTTPS で公開し、実際の Mastodon とテストできるようにするfedify inboxはサーバーが送るアクティビティを受け取る一時的な inbox サーバーを立ち上げるfedify lookupは他のサーバーが投稿したオブジェクトを検査できるようにするfedify lookup --authorized-fetchは使い捨ての鍵ペアを作成し、一時的な ActivityPub サーバーを立てて、secure mode の背後にあるオブジェクトへ署名付きリクエストを送る@fedify/lintは、actor にinboxがない場合のような 20 種類の相互運用バグを検出する ActivityPub 専用リンターである@fedify/testingの mock により、ネットワークなしでテストを実行できる@fedify/debuggerは 1 行でデバッグダッシュボードを追加でき、ブラウザでアクティビティと署名検証結果をリアルタイムに確認できるようにする- 本番環境には OpenTelemetry 計測が組み込まれており、28 種類の span type と 37 種類の metric を提供する
- 監視ガイドと、ActivityPub 向けの負荷テストツール
fedify benchも提供される - 公式ドキュメントは 30 章のマニュアルと 5 本のチュートリアルで構成されており、キューの backlog を確認するための PromQL クエリやアラートルール、Mastodon でアバターを表示させるための属性といった実践的な知見を扱っている
すでに使われている事例と始め方
- Fedify は実際のサービスで使われている
- Ghost の ActivityPub サービス
- ORCID の研究者記録を Fediverse につなぐ Encyclia
- Cloudflare Workers 上で serverless に動作する SiliconBeest
- 韓国のブログプラットフォーム Typo Blue
- 単一ユーザー向けマイクロブログプラットフォーム Hollo
- コミュニティ運営の Hackers' Pub
- チュートリアルは規模別の例を提供する
- 数十行の単一ファイルサーバーは Mastodon からフォローできる
- 約 750 行の画像共有サービスは Pixelfed とフォロー、いいね、コメントまで相互運用する
- コミュニティプラットフォームのチュートリアルは実際の lemmy.ml との双方向 federation を扱う
- Fedify の目標は、より多くの ActivityPub 専門家を作ることではなく、開発者が ActivityPub の詳細を知らなくても連合アプリを作れるようにすることである
- 開始コマンドは
npm init @fedifyである - 助けが必要なら Matrix room または GitHub Discussions を利用できる
1件のコメント
Lobste.rsのコメント
ActivityPubプロジェクトに互いのフォークが多い理由はこれだ。全部を自前で実装するより、他人のアプローチを把握するほうが簡単だからだ
筆者の提案も、実際によく見かけるMisskeyやPleromaのフォークと大きくは違わないように見える。ライブラリにもそれなりの視点やアプローチがあり、あまり大きな制御権は与えてくれないようだ。それでも、サーバ全体をフォークするときのようにUIまで強制されないという利点はある
APを実装している立場からすると、いちばん難しいのはJSON-LDをきちんと扱うための良い方法がないことだ。オブジェクトを標準的な表現に簡単に変換できるなら、相互作用は自然についてくるはずだが、本当にリンクドドキュメントのように使うには効率が悪すぎるし、生のJSONドキュメントのように使うと無数の例外ケースで死ぬ。これまでは後者のアプローチを選んで死んだ
JSON-LDの世界と完全に同じ問題ではないが、まったく無関係というわけでもない
ただ、JSON周辺技術のかなりの部分が似た問題を抱えていると思う。同じ論理スキーマを表現するJSON Schemaのやり方が多すぎて、そのせいでJSON Schema周辺技術と相互作用するのが笑えるほどひどいことになっている。特にOpenAPIスキーマは似ているが同一ではないホラーで、スキーマのドラフト版の違いまで考えなくても十分に悪い
APの「MTA」サービスは、送信箱からメッセージを送り、受信箱でメッセージを受け取る役割を担う。このサービスから見るとJSON-LD文書は、ほとんど塊データに近い。送信者と受信者を見つけるための多少のパースは必要だが、それ以上はあまりない。ストレージもファイルベースでよく、記憶が正しければgo-apはそういう方式を使っている
APの「MUA」は実際のアプリケーションだ。JSON-LDの意味を理解しなければならない側だ。PostgreSQLのようなものを使って文書をjsonbで保存し、生成列やビューでSQLフレンドリーな形を提供できそうだ。そうすれば、オブジェクト型に応じて文書を最も適切に表現する方法を決められる
別の例として、検索サービスもアクターとしてモデル化し、結果を一時的な送信箱に返すようにできる
複数の実装の癖のある挙動とその緩和策を整理した、とても貴重な一覧だ
残念ながらGoActivityPubでは、その半分もまだ実装できていない
文章が最初は技術的な内容から始まったのでありがたかったが、途中から自分のフレームワークの宣伝に方向転換したようで、読む面白さが落ちた
TypeScriptを使う一部の世界では、こうした実装上の癖を再発見しなくて済むのはありがたい。だが、頭の中のモデルとして「この条件と状況ではこういう結果が出て、こういう修正が必要になる」という記録があるなら、TypeScriptではない状況、たとえば兄弟プロジェクトであるGoActivityPubの作者もその苦労の成果を得られる。ここではそうしたこともいくつか扱ってはいるが、記事は特定時点のスナップショットでしかなく、プロジェクトは時間とともにあらゆる相互運用性バグを蓄積していくように見える
現状の代替手段は、私が見る限り、人間が書いたものではないコミットメッセージを全部読みながら、Fedify自身のバグと相互運用性バグを見分けることしかない
特に、リポジトリがAIに「全振り」しているように見えるのに、そうした台帳整理をしていないのは皮肉だ。LLMについて聞いてきた売り文句は、反復的な雑務を自動化することだった。ならClaudeにGitHub Issueを作らせるか、もっとよいのはリポジトリ内の.mdファイルに観測結果とFedifyがそれをどう直すかを文書化させればいい。独自のデバッガもあり、何を意味しているのかわからない「ベストプラクティス」もあるのだから、まさにうってつけの仕事だろう
なぜサードパーティサービスへのリクエストをインラインで実行するのか。こんなのはWebアプリケーションの基本だ。サードパーティサービスと通信しなければならないなら、バックグラウンドジョブに回すべきだ。リクエストに応答するのに不要な情報なら、バックグラウンドジョブに回すべきだ。リクエストハンドラ内でこうしたリクエストをして起きる問題は、熊手を踏んで顔をぶつけるレベルの自業自得であって、ActivityPubとは関係ない
配送が失敗したら再試行すべきだし、スケジュールをどうするか、指数バックオフを使うか、何回やるか、500 Internal Server Errorと410 Goneを同じ失敗として扱うか、といったこともただの一般的なWebアプリケーション開発の問題だ。ジョブキューからサードパーティサービスへリクエストするときに発生する問題であり、ActivityPubとは無関係だ。たいていのWebフレームワークには妥当なデフォルトがある。どんなエラーだったかによって再試行するかを決めなければならない場面でだけ判断が必要になる。410を再試行するのは無駄だが、緊急に解決すべき問題ではない。ジョブキューのメモリ圧迫は増えるだろうが、数時間のうちにアプリケーションを落とす可能性は低い
「拒否されるか見て、別の方法で再署名し、サーバごとにどの方式が通ったか覚えておけ」って、一体何を読まされているんだという気分になる。これがMastodon開発が遅い理由なのか?
「投稿1件が数千件のHTTP配送」だなんて、ネットワークシステムプログラミングとキューイングに強い言語として知られるRubyで、だ
信じがたいし、これをライブラリで包んであるのは良いとしても、それでもちょっとひどい
JavaでActivityPubを実装してみたあと、こういうサーバ間プロトコルは単にgitの上に作るほうがいい、という結論に達した
複雑さのかなりの部分は、gitがすでによりうまく解決している問題をもう一度解くために存在している。これをgitリポジトリ内のJSON文書としてモデル化すれば、ページネーションを扱う必要もなくなる。プロトコルはすでに存在しないデータだけを送ることを保証してくれるし、コミット署名も得られるし、イベント順序の保証も得られるし、この記事で触れられている問題も解決され、履歴まで無料で手に入る。こういうプロトコルには、バグが多くて遅いgitの半端な実装が含まれている、というGreenspunの第十法則みたいなことが言えそうだ
この記事はAIが作った低品質な文章のように読める
もっと具体的に言うと、なぜ物語形式で書いたのかわからない。ここで伝えている事実は、もっと簡潔に、もっと偏りなく書けたはずで、物語としても説得力がない。特にAI特有の言い回しが多いのでそう感じる
楽しく読めるものではなかった。それでも、問題点を指摘してくれたことには感謝しているし、別の形でそれらの問題を読み、修正できることを願う