ピュアP2P分散マイクロブログシステム NostrP2Pを作ってみた

こんにちは。 ryo_grid です。  

今回はピュアP2P分散マイクロブログシステム NostrP2Pというものを作ってみたのでそれについて書いてみます。  

- ひとまず開発物のGitHubリポジトリはこちら
  - [ryogrid/nostrp2p](https://github.com/ryogrid/nostrp2p)
  - [ryogrid/flustr-for-nosp2p](https://github.com/ryogrid/flustr-for-nosp2p)

アイキャッチ画像です。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12325/9fce752c-3d73-fa48-10ea-0770330a3b38.png)

# 前提知識
- [Nostr](https://github.com/nostr-protocol/nips) プロトコルについてのざっくりとした理解
  - [こちらの記事](https://zenn.dev/mattn/articles/cf43423178d65c)などに目を通しておいていただければ十分かと

# 開発しようと思った経緯
- 元々NAT透過なオーバレイ上で動作するピュアP2Pなアプリケーションを作ってみたかった
  - [DHTベースの分散KVS](https://qiita.com/ryo_grid/items/9a82d7230fbc4a0875c1)を作ったことがあったが、NATの壁を超えることはできなかった
- 上の思いから、ひとまずgossipプロトコルなどで雑にNAT透過なオーバレイを実装できないか考えていた
  - => そのものずばり、どころかよりインテリジェントな実装である[weaveworks/mesh](https://github.com/weaveworks/mesh) を見つけた 
- meshは非常に良くできているが、ノード間で信頼性のあるコネクションベースの通信はできなかった。また、利用方法に少々クセがあり、トライアンドエラーを繰り返さないと使えない感じだった
  - => SCTPプロトコルによる信頼性のある通信路をmeshで構築されたオーバレイNW上に張る、言ってみればmeshのラッパーライブラリ的なものである[ryogrid/gossip-overlay](https://github.com/ryogrid/gossip-overlay)を書いたりした
- meshをいじり始めた頃、分散SNS（マイクロブログ）の実装等に利用されるNostrプロトコルというものを知った
- ついでに、TwitterがXになるアレコレなどもあり、その他の分散SNSについても調べてみた
- 結果、以下のようなことを思った
  - サーバの運用者にかかる諸々の負荷が大きすぎていずれ破綻しないのだろうか？
    - Misskey.io においては [このような状況であったりする](https://misskey.io/notes/9brqui38ib)
    - 上は極端な例にしても、全体的にボランティア的なリソースに依存しており、少なくとも私にはあまり健全ではないように思えた
- 以上のあれこれが入り混じり、また、タイミングがおおむね一致したことから掲題に挙げたNostrP2Pを作ってみることにした
  - （ピュアP2Pでの分散マイクロブログの実装が、私が探した限り見つからなかったというのも一つの理由。もしご存じの方がいましたらコメント欄などで教えていただければ幸甚）

# NostrP2Pのコンセプト
- **利用者皆の貢献により構成されるシステム**
  - 課題感: 既存の分散SNS（Mastdon、Nostr、Bluesky、etc..）の設計は、サーバの運用者にかかる金銭的・作業的負荷が高くシステム全体としてみた時の健全性に欠ける（ように感じられる）


# NostrP2Pの特徴
- gossipプロトコルによるブロードキャストを軸にしたシステム
  - gossipプロトコルによるオーバレイネットワークの構成とその上での各種メッセージングが可能な [weaveworks/mesh](https://github.com/weaveworks/mesh) ライブラリを通信基盤とする
- パフォーマンスやデータの一貫性より実装の容易さとシンプルさに重点を置く
  - 結局のところは省工数にしたいという理由に落ちるかもしれないが、この手のシステムで複雑な仕組みを入れると安定して動くようにするのが大変
  - 上の理由からDHTなどの構造化の仕組みは（ひとまず）採用していない
- 全体的にファジーにやる（ex: メッセージのロストも少量であれば許容する）
  - 他の分散SNSと比べてピュアP2Pというまともなパフォーマンスで動かすのが難しいアーキであるので、あまりリッチな機能は提供しない
- 各サーバはオーバレイNW上で動作させる
  - NAT内のサーバも参加可能
  - NAT越えはグローバルIPを持つサーバによる中継で行われる
- [Nostr](https://github.com/nostr-protocol/nips) プロトコルの考え方とデータ構造を下敷きとしている
  - Nostrプロトコルを下敷きとした理由
    - 実装が比較的容易であるため
    - Nostrのエコシステムを流用できるため
    - Nostrプロトコルベースのシステムと連携できれば面白いかもしれないと考えたため
  - 公開鍵とそれを用いたデータへの署名を認証基盤として利用する、マイクロブログのアプリケーションを実現するための各種メッセージの設計、メッセージのデータ構造、など
  - ただし、前提とするアーキテクチャが異なるため、最適化の結果として互換性はないものとなっている
- 各ユーザが自身のマシンにサーバを立てる。各ユーザが利用するクライアントは基本的に自身のサーバのみと通信する
- Nostrプロトコルが汎用性とデータ可読性に重きを置いた結果、マイクロブログシステムで採用した場合に通信量が比較的大きなものとなったことを踏まえ、特に通信量が多くなる部分に絞ってバイナリフォーマットにシリアライズする。また、アプリケーションおよびアーキテクチャ特化の最適化で通信量を低く抑える
- pullよりもpush（詳細後述）
- クライアントは主にスマートフォンなどのモバイルデバイスで利用されることを前提とし、それらのデバイスで特に重要となる通信量や電力消費の観点での最適化を図る
　

　システム構成概念図  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12325/83fc6146-0e15-a363-ddac-d3d373c21c48.png)

# 詳細（設計・実装）
- 各ユーザが自身のマシンにサーバを立てる。クライアントでのアクセスは自身のサーバに対してのみ行う
  - 自宅マシン（つまりグローバルIPを持たないマシン）に立てた場合、スマホ等のモバイルデバイスからモバイル回線で素直にはアクセスできないが、tailscale等でVPNを張ることで対処してもらう想定
  - **ただし、パブリックIPで運用するサーバもいないとオーバレイできる系が成立しない**
    - => パブリックIPで運用する場合の考慮としてクライアントから投稿等を行う際には署名をつけ、サーバはその検証を行う
- pullよりもpush
  - ブロードキャストして、followしてる者にだけ受け取ってもらい、他のものには捨ててもらう
    - 原則、ブロードキャスト時に受け手はオンラインである想定
- Go言語でmeshライブラリを利用する前提での設計
  - (残念ながら、meshにより構成されるオーバレイNWに接続できるライブラリ実装はGo以外の言語にはない。が、他の選択肢も見当たらず)
  - meshでは64bitのIDを各ノードが持っている
  - 各ノードはオーバレイNWに参加している全ノードのIDを知っている
    - 最新化まで遅延はあるが、100ノード程度までであれば、2-3秒の範囲に収まるのではないか・・・と思う
    - 公開鍵の下位64bitをノード（サーバ）のIDとすることで、ユーザに対応するノードのIDを探索するといった処理を不要にできる
- 秘密鍵
  - クライアントだけが持っていれば良い
  - サーバには公開鍵だけを設定しておく。その情報があれば、対応する秘密鍵を持っているユーザ以外からの投稿などは弾くことができ、ユーザは公開鍵と対応させてあるノードIDによって識別可能なため、他のサーバがメッセージをユニキャストすることもできる
  - なお、秘密鍵・公開鍵はNostrのものがそのまま利用できる
- あまり長期間のデータは蓄積しない
  - （プロフィール情報などを除き）古いデータを参照する機会は特殊なクライアントでない限り少ないので、潔く割り切る
  - これにより、サーバのメモリ使用量やストレージ使用量が低く抑えられる。また、データが多くならないということはサーバが要求されたデータを探す際の処理負荷なども低く抑えられる
- サーバ間通信はバイナリで行うがやりとりするデータの構造はNostrプロトコルと同一
  - バイナリフォーマットにはMessagePackを採用。ProtoBufも試したが手間の割にデータサイズの変化が小さかったため不採用とした
  - サーバを立てる時の面倒が増えるので、通信を over TLSで行うといったことはしない
    - meshはアプリケーションで共通に設定したパスワードから共通鍵を生成し暗号化を行えるようだが、パフォーマンス低下の懸念やOSSとの相性の悪さから当該機能は利用しない
    - もしやるにしても、E2EEを別途行うといったことになろうが、ブロードキャストと相性が悪いという課題がある・・・
- クライアント
  - 自身のサーバとREST I/Fで通信
    - 基本のデータ形式はJSONテキスト
    - データ要求へのレスポンスのみMessagePackでシリアライズしたバイナリでサーバから返ってくる
    - サーバとの通信頻度が低い方がモバイルデバイスではモバイル回線用のアンテナを休ませることができ電力消費を抑えられ、また、サーバ側としては、ある程度の数をまとめて送った方がHTTPレイヤでのgzip圧縮の効果が高まるため、現在の実装では10秒に一回サーバへリクエストを送り、それに対し、サーバは前回のリクエスト以降に他のサーバから受信したデータをまとめて送るようになっている
  - 前述の通り、クライアントから投稿を行う際などユーザに紐づく情報の追加・更新時には署名をつける
  - 逆に、NostrP2Pの設計では、自分用サーバは信頼できる存在であるため、クライアントは受信したデータの署名検証は行わない。従って、サーバは通信量削減のため署名情報の部分は空にして送信できる

# サポートする機能
- 投稿（ポスト）
  - 現状、実装をシンプルなものとするために、オーバレイNW上にいる全ユーザにブロードキャストする
    - （正直なところ、万が一ユーザが数百のオーダーなどに達した場合、この設計では限界があるとは思う） 
    - （この場合、フォロワーの情報を管理するようにし、ブロードキャストではなくマルチキャストに切り替えるような修正が必要と思われる。また、木構造を成す形で転送するなどして、各サーバが直接に送信するサーバの数が増えないようにする必要があるかもしれない）
- プロフィール
  - 更新したらブロードキャスト
  - クライアントがポストを表示しようとした際に、併せて表示すべきプロフィール情報（ユーザアイコンやユーザ名が含まれる）が無い場合自分用サーバに取得リクエストを行い、自分用サーバは要求の応えられなかった場合、対応するユーザのサーバに取得要求を送る。サーバがオフラインであった場合は後ほど取得要求を再送する
- フォロー
  - 現状、グローバルなタイムラインで見つけたユーザをフォローするという形しかない
    - （クライアントを作りこめば指定した公開鍵を持つユーザをフォローする、といったUIは提供可能）
- リプライ（メンション）
  - 投稿が見えていれば誰にでも行えるが、そのやり取りは当事者間だけにしか見えない
- ファボ（Like）
  - post元のユーザのサーバにユニキャストする
    - 送信時に相手サーバがオフラインであった場合は後ほど再送する
  - ファボした者は対象の投稿にファボしたことのみ分かり総数は分からない。された者は総数が分かる
  - それ以外のユーザはファボの状況に関して何の情報も知り得ない
- リポスト・引用リポスト
  - リプライの場合と異なり、全ユーザにブロードキャストする
  - 引用リポストでリポストしたポストをクライアントが未受信の場合はサーバにリクエストし、自分用サーバは要求されたポストを持っていなければ、それをポストしたユーザのサーバに取得要求を送る。サーバがオフラインであった場合は後ほど取得要求を再送する
- ハッシュタグ
  - サーバでのポストの探索処理の負荷増大、NW全体への問い合わせの発生を要するのでサポートしない
- 投稿（ポスト）の削除
  - 現時点では未実装
  - 投稿した際に、それを全サーバにブロードキャストする設計を採用できている（スケーラビリティの観点で問題が出ていない）間であれば、同様の方法で削除リクエストを送り、受信側がそのデータを削除する、という設計で実装は可能
    - ただし、筆者が開発したものをカスタマイズしたサーバなり、筆者以外が開発した実装なりが現れ、それが削除リクエストを無視する場合は削除されない。
    - リクエストを送信したタイミングでオンラインでなかったサーバのデータも削除されない
      - 複数回送信してもタイミングが合わなければダメなので、極論、絶対に消える保証はない
  - サーバが分散しており、それらに特権的な操作を強制できる者がいない以上、一度投稿してしまった内容は二度と削除できないと考えるのが一番安全なユーザの態度となる

# デモ
Webクライアントでデモサーバに接続してみます。  
動くと下のような表示がされるはずです。  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12325/9fce752c-3d73-fa48-10ea-0770330a3b38.png)


## Webクライアント
- [https://nostrp2p.vercel.app/](https://nostrp2p.vercel.app/)  
  - Flutter製
  - PC、スマホ、タブレットいずれのプラットフォームでもChromeでのアクセスを推奨

## クライアントの設定
画面右上の歯車マークをクリック・タッチして表示される画面で以下を行います。  


- ①秘密鍵としてTrialアカウント用の以下を設定
  - nsec1uvktv4u3csltg98caqzux3u0kawxz3mppxjqw40lcytqt52kdslshwr2xp
- ②サーバのアドレスを設定
  - デモサーバである以下のアドレスを入力します
  - https://ryogrid.net:8889
    - 末尾に空白スラッシュはつけないで下さい
      - (謎仕様なので修正します...) 

**注: saveボタンを押してもうまく保存されない場合があるようです。入力エリアの上に current server address という表示とともに入力内容が表示されていないと設定が反映されていない状態なので、表示されない場合は何度か押してみてください。**  
　
書き込みもできないとつまらないかと思いますので、Trialアカウントでのみになりますが、投稿やプロフィールの変更なども可能としてあります。

**ですが、** NostrP2Pでは、投稿は本来自分用のサーバと通信して行う想定のシステムでありデモサーバを介して行うというのは例外的な運用となっています。
　
自身のアカウントを持ちたいと考えた場合、自分用のサーバを立て、そのサーバをデモサーバ（実はオーバレイNWのブートストラップサーバも兼ねています）に接続し、自身で立てたサーバ経由で投稿等の操作を行う必要があります。

# NostrP2Pに自身のアカウントでアクセスする

## ステップ1: 自分用サーバを立てる
- サーバの立て方は[NostrP2PのGitHubリポジトリ](https://github.com/ryogrid/nostrp2p)にあるExamples-Server Launchのところをご参照下さい。  
  - コマンドラインオプションの中に **-b** というオプションがありますが、そこはデモサーバの **ryogrid.net:8888**を指定すればOKです。  
- サーバのバイナリは以下にビルド済みのものを置いてあります。  
  - [https://github.com/ryogrid/nostrp2p/releases/tag/latest](https://github.com/ryogrid/nostrp2p/releases/tag/latest)
  - 動作させたいプラットフォーム用のバイナリが無い場合は、お手数ですが自前でのビルドをお願いします 〇刀乙
- なお、秘密鍵と公開鍵はトライアルアカウント用のものとは別のものを使うことになります
- 鍵ペアは以下で生成できます
  - $ .¥nostrp2p_server.exe genkey
    - Windowsの場合
  - nsecから始まる鍵が秘密鍵ですが、それは他人に知られることのないよう管理して下さい。それを他人に知られた場合、NostrP2Pの上で自身になりすました投稿などを他人が行うことが可能となってしまいます
    - デモにおけるTrialアカウントの秘密鍵は公開していますが、それはデモのためのアカウントであるための特殊な例だと考えて下さい

## ステップ2: 自分用サーバにクライアントからアクセスする
- 利用するクライアント種別によって少し方法が変わります
  - なお、アクセスするポート番号はいずれでもサーバの起動時に **- l** オプションで指定したもの + 1です
    - 例えば、**-l** オプションは省略可能ですが、その場合は 127.0.0.1:20000 を指定したものと見なされるので、クライアントで指定する際のポート番号は20001になります
- 次のセクションは、サーバがグローバルIPアドレスを持つマシンではなく、プライベートネットワーク内に立てられた前提での記述です
  - グローバルIPアドレスを持ち、インターネットからアクセス可能な形で立てた方はover TLS化の説明等は不要でしょう^^
    - と、言いたいところですが、NostrP2Pのサーバは自身でTLS通信をさばけまして、fullchain.pem (証明書公開鍵) と privkey.pem （秘密鍵） をサーバ起動時のカレントディレクトリに同名で配置し、サーバ起動時のオプションに '-s true' を加えることで行えます
    - over TLS化できるリバースプロキシなどを用いない場合はこちらをご利用下さい

### Webクライアント（ https://nostrp2p.vercel.app ）を使う
- Webブラウザのセキュリティの制限から、サーバ側のREST I/F が暗号化対応していない（over TLSでない）場合、通信がブロックされて、動作しません
- 従って、over TLS化、言い換えれば元はHTTPで開いているREST I/Fの口をHTTPS化する必要があります
- 他にも方法はあると思いますが、ひとまずこれをプライベートネットワークでも簡単に行う方法の1つとしてtailscale（無料VPN構築ツール・サービス）のリバースプロキシ機能というものを使う方法があります
  - ["Tailscale 組み込みのリバースプロキシでVPN向けWebアプリをHTTPS対応する - DevelopersIO"](https://dev.classmethod.jp/articles/use-tailscale-builtin-reverse-proxy/)
  - これを利用することでサーバを立てたマシンや、VPNに参加している端末からデモクライアントを利用して自分用サーバにアクセス可能になります
  - 細かい話をすっとばすと、tailscaleを導入して上の紹介記事を参照しつつ "/" へのアクセスを "http://127.0.0.0:<上述のポート番号>/" にマッピングして、tailscaleのDNSがサーバを立てたマシンに割り振った "うんたら.tailed数字.ts.net" というアドレスをサーバアドレスとしてクライアントに設定すればOKです
  - URLはこうなります -> https://うんたら.tailed数字.ts.net/
  
### ネイティブクライアントを使う
- 以下に置いてある各種ネイティブクライアントを用いる場合は自分用サーバのREST I/Fの口はHTTPのままでいけます 
  - https://github.com/ryogrid/flustr-for-nosp2p/releases/tag/latest
  - (Webブラウザのような制限が無いので）

# 設計・開発において苦労した点
- 設計においてどう割り切るかの判断に苦労
  - リプライは当事者たちにしか見えない、ファボは受けたものにしか分からない、というのはサーバ間の通信量を抑えるための仕様であるが、ユーザ目線で本当にそれでいいかの決めには少々時間がかかった
- クライアントの実装が大変
  - サーバよりもこちらに時間がかかった
  - [uchijo/flustr](https://github.com/uchijo/flustr) というNostrプロトコルベースのマイクロブログクライアントが<del>パクる</del>参考にするのにちょうどよい程度のコード規模で存在したので、これをNostrP2P向けに改修し、エンハンスすることでクライアントを作成した
    - uchijo氏の great work に感謝している
    - Flustrが採用しているFlutterフレームワークは書いたことがあり、Webフロントエンド系のフレームワークがあまり好きでない私にとっては渡りに船であったが、使ったことのないriverpodが状態管理に採用されており、その理解に苦戦した
  - dart/flutter向けのサードのライブラリはそれなりに充実しているが、Webビルド非対応のものが多かった
    - (コードベースはiOS向けビルドも可能としてあるが、お布施を払わないといけないのと、いちいち審査を通さないといけないのを避けるため、iOS端末対応はWebアプリで済ませたい、という前提がある）
    - 例えば、HTTP2のライブラリはWebビルド非対応であったので、HTTP2の採用を見送るといったことがあった
    - Flutter Webの安定度がまだまだで、モバイルのブラウザだと素直には動いてくれず、バッドノウハウ的な方法でどうにかすることがしばしばあった

# 残る課題
- meshライブラリのGitHubリポジトリには100ノード程度まではスケールするだろう、といった記述があるが、逆に言えば、それ以上の規模では使い物にならない可能性がある
  - meshライブラリはgossipプロトコルベースながら比較的インテリジェントなルーティングを行うが、それがある意味仇となり、ノード数が多くなった場合のルーティングの決定や、ノード情報（コードを読むとNW全体のトポロジ情報かそれに近いものを同期しているように見える）の更新までにかかる時間が大きくなりすぎるといったことではないかと推測しているが、現状定かではない
  - 実際に100ノード程度が限界であった場合、通信基盤のところも自前で実装する必要が出てくるかもしれない
- やっぱりpostを全ユーザにブロードキャストする設計は（サーバ数が増えた場合）無茶では？
- 署名検証の仕組みがあるため、なりすましての情報発信は不可能だが、いやがらせで同一の公開鍵を指定してサーバが起動された場合、NW上に同一のIDを持つノードが存在する状態となり、本来届くべきサーバに届かないメッセージが出る可能性が高い（特にユニキャスト）
- DOSアタック的にメッセージを大量送信された場合に、NW全体が機能不全に陥る可能性があるが、現状特にそのような攻撃に対する対処が実装されていない
- いまどきの人たちがプライベートで所有しているPCというと、ラップトップがせいぜいであったりして、そうだとすると、それを24時間動かせというのは無茶があるのではないか、と思ったり
  - => 結局、利用するマシンリソースが何であれ、元々何らかサーバを運用しているといった人しか利用し得ないシステムなのではないか？ そうなると母集団が小さくなるので辛いな、などと思ったり
  - 一つのソリューションとして、適当なAndroidスマホを調達してサーバ機にするという手はあるかもしれない
    - [AndroidスマホでNostrP2Pのサーバが動いた - Ryoの開発日記 Neo!](https://ryogrid.hatenablog.com/entry/2024/06/02/152034)
- NAT外にいる、もしくはNAT内にいるがNAT外からアクセス可能としてあるサーバを立ててもらえるか？
  - 異なるNAT内のサーバ同士が通信するには、上のようなサーバによる中継が必要
  - ブートストラップサーバ（=上のようなサーバでかつアドレスを公開しているもの）も私が公開しているもの以外に存在しないと単一障害点になってしまうという話はある
    - ダウン・運用停止した場合に、既に接続済みのサーバは関係ないが、新たに参加しようとしたサーバ、もしくは再接続しようとしたサーバは困ってしまう


Enjoy!