おひとり様ActivityPubサーバーの構成
飛び交うリクエストの対応と処理
わたし自身、どうすればいいのかまったく分かってなくて、右往左往検索しまくってどうにかそれなりになった。
…という経緯もあるので、わたしの「おひとり様ActivityPubサーバー」の構成、実際なにやってんの?について以下にメモします。文字通り「備忘録」です。
見よう見真似の現物合わせのやっつけ仕事で詳細なんかは役に立たない情報だと思いますが、この程度でもFediverseに参加できるということで、Fediverse参加へのハードルを下げる参考にでもなれば幸いです。
- 飛んでくるリクエストを捌いてログファイルを保存する
- ログファイルを開いてHTTP Signature(署名)の検証をする
- ActivityごとにJSONファイルを保存する
- ActivityPubに対応 / 運用するためのリストたち
【apacheでリダイレクトの設定をする】
ここがスタート。
アクセスされたら(リクエストが飛んできたら)、リクエストを捌くSCRIPTにリダイレクトする。
レンタルサーバーなので「httpd.conf」を直接いじることはできないけど、ホームディレクトリに「.htaccess」でリダイレクトの設定ができる。
以下.htaccess一部抜粋
RewriteEngine On
RewriteCond %{QUERY_STRING} ^resource=(.*)$
RewriteRule webfinger.* SCRIPT?re=Finger&USERNAME=%1 [L]
RewriteRule nodeinfo$ SCRIPT?re=Nodeinfo0 [L]
RewriteRule nodeinfo/2\.1$ SCRIPT?re=Nodeinfo [L]
RewriteRule host-meta$ SCRIPT?re=Hostmeta [L]
RewriteRule USERNAME$ SCRIPT?re=Person [L]
RewriteRule USERNAME\#main-key$ SCRIPT?re=Person [L]
RewriteRule USERNAME/inbox$ SCRIPT?re=Inbox [L]
RewriteRule USERNAME/outbox$ SCRIPT?re=Outbox [L]
RewriteRule USERNAME/followers$ SCRIPT?re=Followers [L]
RewriteRule USERNAME/following$ SCRIPT?re=Following [L]
RewriteRule USERNAME/items/([0-9\-]+)$ SCRIPT?re=Outbox&Item=$1 [L]
「webfinger」「nodeinfo」「nodeinfo2.1」「host-meta」「type:PersonのJSON」は静的ファイルを用意した。
何を要求されているか(何にアクセスされたか)パラメータをつけてスクリプトに渡す(リダイレクトする)
リダイレクトされてリクエストを受けたスクリプトが要求された静的ファイルをそのまま返す(※ファイルに応じたmimetypeをヘッダに付与)
(USERNAMEとあるけど、おひとり様なのですべてわたしの情報を返すだけ)
「following」「followers」
フォロー、フォロワーの情報。ウチの場合はテキストファイルでリストを保存してるのでそれを返す。
お相手サーバーは配送先としてこちらのフォロー情報を見る。
「outbox」
「プロフィール」として表示される、自分の過去投稿を返す。
丁寧にやるならすべての過去投稿を20とか50個ずつページング指定に応じて返すべきだろうけど、ウチはテキトー。直近の20個ぐらいしか返していない。
過去投稿はIDつきのURLで参照されることもある(Fediverseで個別リンクとして表示されている) ので、パラメータを見て過去投稿をひとつ返す。
以上はすべて「GET」リクエストで飛んできて、リクエストを捌くスクリプトがその場で情報を返している。
「POST」で飛んでくるリクエストはすべて「inbox」宛として、Activityに応じた対応/処理が必要。
スクリプトは未処理ログファイルとして命名規則どおりにファイル名をつけて保存。おひとり様ActivityPubサーバーのスクリプトに処理をまかせる。
【ログファイルを処理する】
未処理のログファイルをActivityに応じて処理する。
Activityの対応については「おひとり様ActivtyPubサーバーの自作実装」あたりを参照ください。
リクエストを捌くスクリプトはPOSTされたFORMデータ(ActivityのJSON)を読んでActivityのタイプを判定してログファイル名の一部に記載する。
-
リクエストを捌くスクリプトが保存したログファイル
- 宛先
- Activity
- タイムスタンプ(ナノ秒)
「inbox-Create1741482204.98693.log」
ログファイルには、環境変数「%ENV」で取得できるものすべてとPOSTされたFORMデータ(ActivityのJSON)を記録しておく(ただのテキストファイル)
(「%ENV」はperlの場合。rubyやphpは各々同じような変数が用意されている)
以下は、差し障りのありそうな部分(IPアドレスなど)を改変or削除したログファイルの実例。
[ENV] ============
CONTENT_LENGTH 1335
CONTENT_TYPE application/activity+json
CONTEXT_DOCUMENT_ROOT /home/users/tokoroten
CONTEXT_PREFIX
DOCUMENT_ROOT /home/users/tokoroten
GATEWAY_INTERFACE CGI/1.1
HTTPS on
HTTP_DATE Thu, 27 Feb 2025 22:45:55 GMT
HTTP_DIGEST SHA-256=OpOxiEpG576GuvHJxVjr4RQGwZZQ0PT0VtdROwbbwJw=
HTTP_HOST tokoroten.doncha.net
HTTP_SIGNATURE keyId="https://DOMAIN/users/USERNAME#main-key",algorithm="rsa-sha256",headers="(request-target) content-length date digest host",signature="atw/rkD0sOiSwmx0nBcrTF2eZdzMVdFd1NfXsqh+9k+EVUB8rA+D7bOw2cM5rJ82m8xDduuT4VcVcik/4X4AfooXyfBiCKRrCWARhHy83ThdDH/6yKajYtBPyEQ94nWuNxpQ6aYwN3c3Nh2A/PYk0iSLTbLgOwOWtTXa/VWGzuN2jSA0R1erL98edNi4nD0xoExKIaLsfeEfz3Ot3KTPsdJUd85w7tBuqPZKu1qFqGPJ3M5qKhAujiFxLSZj2C2XNXf8gPifwMG14Fe10pLHYdonhUzX/MiMULwuYV5NvxJ5a/7/WuPQ4Sr9mBmnJyXn3V02u1P2b+kBNmXevsEaPA=="
HTTP_USER_AGENT Akkoma 3.14.1-0-g5ae392f
HTTP_X_BACKEND lolipop.jp
HTTP_X_FORWARDED_FOR NNN.NNN.NN.NN
HTTP_X_FORWARDED_HOST tokoroten.doncha.net
HTTP_X_FORWARDED_PROTO https
NSS_SDB_USE_CACHE YES
[FORM] ============
{
"@context":["https://www.w3.org/ns/activitystreams"],
"actor":"https://DOMAIN/users/USERNAME",
"cc":["https://DOMAIN/users/USERNAME/followers"],
"context":"https://DOMAIN/contexts/b4530869-5257-4e23-9912-b0f40365e403",
"directMessage":false,
"id":"https://DOMAIN/activities/c406aae0-5396-4b75-9505-84ab0430f565",
"object":{
"actor":"https://DOMAIN/users/USERNAME",
"attachment":[],
"attributedTo":"https://DOMAIN/users/USERNAME",
"cc":["https://DOMAIN/users/USERNAME/followers"],
"content":"投稿本文",
"contentMap":{"ja":"投稿本文"},
"context":"https://DOMAIN/contexts/b4530869-5257-4e23-9912-b0f40365e403",
"conversation":"https://DOMAIN/contexts/b4530869-5257-4e23-9912-b0f40365e403",
"id":"https://DOMAIN/objects/1ece824a-7e41-40fe-8749-ad1f416eac9d",
"published":"2025-02-27T22:45:53.718980Z",
"sensitive":null,
"source":{
"content":"投稿本文",
"mediaType":"text/plain"
},
"summary":"",
"tag":[],
"to":["https://www.w3.org/ns/activitystreams#Public"],
"type":"Note"
},
"published":"2025-02-27T22:45:53.718918Z",
"to":["https://www.w3.org/ns/activitystreams#Public"],
"type":"Create"
}
何はなくても、HTTPリクエストの署名を検証するために環境変数「%ENV」が必要になる。これは必須事項。
HTTP_SIGNATURE
keyId="https://DOMAIN/users/USERNAME#main-key",
algorithm="rsa-sha256",
headers="(request-target) content-length date digest host",
signature="atw/rkD0sOiSwmx0nBcrTF2eZdzMVdFd1NfXsqh+9k+EVUB8rA+D7bOw2cM5rJ82m8xDduuT4VcVcik/4X4AfooXyfBiCKRrCWARhHy83ThdDH/6yKajYtBPyEQ94nWuNxpQ6aYwN3c3Nh2A/PYk0iSLTbLgOwOWtTXa/VWGzuN2jSA0R1erL98edNi4nD0xoExKIaLsfeEfz3Ot3KTPsdJUd85w7tBuqPZKu1qFqGPJ3M5qKhAujiFxLSZj2C2XNXf8gPifwMG14Fe10pLHYdonhUzX/MiMULwuYV5NvxJ5a/7/WuPQ4Sr9mBmnJyXn3V02u1P2b+kBNmXevsEaPA=="
何を対象として署名をしているのかを確認するには「headers」を見る必要がある。
Mastodon、Misskeyなどのメジャーどころだけじゃないので、署名対象の決め打ちをしてはいけない。 かならず飛んできたリクエストのSIGNATUREにある「headers」を確認して、そこに列挙されている要素を対象として署名の検証をする。
ということもあるので、ウチではログファイルには環境変数「%ENV」で取れるものをすべて取っている。
MastodonなどちゃんとしたActivityPubサーバーだと、リクエストを受けた時点で署名の検証まで行なっている、と思う。ログファイルとして残して処理はまた後で、というのはウチだけの話。
- ログファイルを開いてHTTP Signature、署名の検証をしたら
- 検証がOKだったら、ログファイルの中のFORMデータ(ActivityのJSON)を別ファイルに保存
- 検証の成否にかかわらず、ログファイルを削除
【Activity(JSONファイル)の処理】
HTTP Signatureの認証ができたJSONファイルを所定のディレクトリに保存。
- accept
フォロー申請が受けつけられた時に送られてきたJSONファイル - archive
JSONファイルの別途保存(MentionやDMなど) - followers
フォローされた時のJSONファイル - following
フォローした時のJSONファイル - is_announced
アナウンスされたJSONファイル - is_liked
イイねされたJSONファイル - like
イイねしたJSONファイル - mention
Mention、わたし宛に送られてきたJSONファイル - timeline
投稿のJSONファイル - verified
認証できたものの本文などの読みとりエラーで対応できなかったJSONファイル
- 保存する時の命名規則はだいたい3パターン
- タイムスタンプ / アカウント名
- わたしの投稿のID / アカウント名
- アカウント名 / タイムスタンプ
ウチの場合、ひと様のデータは保持しない、というのが前提。
基本的にすべてのデータは削除している(例外はDMのようなわたし宛のJSONファイルぐらい)
- おのおのJSONファイルは
- 手動削除
accept,archive,followers,following,mention - 自動削除
timeline: 上限20個それ以上は古いものから削除
is_announced,is_liked,verified: 3日保存
like: 7日保存
followers: UndoやBlockなど、フォロー解除されたら削除
following: BlockやRejectされたら削除
「timeline」にあるJSONファイルを時系列逆順で並べて表示して、最低限のおひとり様ActivityPubサーバーとなります。
「署名の検証→JSONファイルを振り分け保存or削除」
という作業は5分ごとのcronで処理。または、おひとり様ActivityPubサーバーにアクセス・表示させる時に処理。
【ActivityPubに対応 / 運用するためのリストたち】
ActivityPubの仕様を遵守するためだったり、運用上あった方が便利だったりで、リストとして持っているものが別途以下。
- blocked
- deny_servers
- ngword
- follower
- following
- forwarding
- shared_box
blocked
ブロックされたら、お相手の投稿が見えちゃいけない。ひょっとしたらAnnounce(ブースト、リノート)でブロックされたお相手の投稿が流れてくるかもしれないので、このリストを参照してから表示or削除をするように。
deny_servers
スパム対策でサーバー単位でブロックするようにした。今のところ100弱ほどこのリストに登録。
ngword
NGワード。必要ないかなと思ったんだけど、念のため。
following / follower
フォロー / フォロワーのアカウントリスト。JSONの保存と同じタイミングで追加や削除。
forwarding
転送したNoteのIDリスト。Deleteが飛んできたら、それが転送したNoteだったらDeleteを転送するための転送履歴のリスト。
shared_box
Activity(投稿など)の配送先は基本的にユーザー単位になる、んだけど
メジャーなサーバーだとそこにフォロワーさんが複数いることもある。ひとりひとり個別に配送する負荷もあったりするんで、Shared_boxというサーバー全体での郵便受けみたいなものがあって、そこに投函したらお相手のサーバーがわたしのフォロワーを確認して各ユーザーさんに配送してくれる仕組みが用意されている場合がある。
(type:PersonのJSONに「sharedInbox」という要素があれば利用できる)
【投稿を表示する時のためのキャッシュファイル】
- actor
- latest_noteid
actor
iconや表示名を表示するためだけにリクエストのキャッチボールをすると20個とはいえ表示に時間がかかりすぎる。
ということで、ユーザーさんのプロフィール情報の一部(icon / summary / name)を30日保存(保存期間中にUpdateが飛んできたら更新)
latest_noteid
20個しか表示(保存)しないので、バズった投稿がアナウンス(ブースト)などで流れてくると、表示の20個がほとんど同じ投稿、ということになりかねない。
ということで、NoteのIDを直近の25個保存して、確認。ダブりを表示しないように。
【補足と蛇足】
自分の投稿は一意のIDが必要なので、データベース(SQLite)を使ってますが、それ以外はファイルの保存と削除だけ。
- レンタルサーバーのApacheで.htaccessを設定
- すべての処理はperl
- 実質Activityはファイルで管理
これで全部。というチープな構成です。
配送の失敗、投稿(Note)受信の取りこぼしなどは気にしない…「そういうこともあるよなあ」で済ませてるのも使うのが自分だけだしまあいいか、というイイ加減なものです。この程度でOKとはおおっぴらに言いにくいんですが。
ActivityPubの仕様は最低限守れてるかなあ、と思います。
ちなみにがっつり省いた部分が 「6. クライアントからサーバへの通信」
たぶん。ActivityPubサーバーというのは、クライアントを作ったりPWAを作ったりして、サーバー側でAPIを用意するところまでがセット。
このクライアント対応はかなりコスト(時間と労力)のかかる部分だろうと思います。
おひとり様の場合、飛んでくるリクエストはすべて他サーバーからのリクエスト。
リクエストはすべて他サーバーで適切に処理されたActivityです。
クライアントからサーバへの投稿で、 周りのアクティビティなしでオブジェクトを作成することも可能である。サーバは outbox への POST リクエストで、 Activity のサブタイプでない 正当な [ActivityStreams] オブジェクトを受け入れなければならない(MUST)。サーバはこのオブジェクトを Create Activity の object として添付しなければならない(MUST)。非一時的オブジェクトに関しては、 サーバはラッピングした Create とラップされた Object に対して、id を 添付しなければならない(MUST)。
上記にあるように、クライアントからのリクエストを受ける場合は対応が必要だけど、おひとり様の場合、わたしがクライアントを使って投稿するならともかく、ただブラウザで投稿するだけ。
クライアント⇔サーバーの対応は不要、サーバー間でキャッチボールするリクエストをケアすればOKじゃないでしょうか。
致命的に間違ってる / 足りないところがあったらぜひ教えてください。
[2025-04-13 09:47:02] v1.0.3
[2025-03-17 07:35:58] v1.0.2
[2025-03-10 07:33:07] v1.0.1
[2025-03-09 14:51:03] v1.0.0