NAT透過なピュアP2P分散マイクロブログシステムの開発

作るものはこんな感じ。
名称はNostrP2Pとしました。
なお、以下の内容に対応するところは随時更新していて、最新の内容を追いかける時はコチラをご参照下さい。
ソースコードは以下にあります。
- サーバ(Golang)
- クライアント(Flutter)
開発内容概要
- コンセプト
-
利用者皆の貢献により構成されるシステム
- 課題感: 既存の分散SNS(Mastdon、Nostr、Bluesky、etc..)は言うて、サーバ運用者にかかる負荷が高く、その割に見返りもない
-
利用者皆の貢献により構成されるシステム
- その他のポイント
- gossipプロトコルによるブロードキャストを軸にしたシステム(メッセージングはweaveworks/mesh ライブラリが担う)
- パフォーマンスやデータの一貫性より実装の容易さとシンプルさに重点を置く
- 結局のところは省工数にしたいという理由に落ちるかもしれないが、この手のシステムで複雑な仕組みを入れると安定して動くようにするのが大変
- 上の理由からDHTなどの構造化の仕組みは(ひとまず)採用しない
- 全体的にファジーにやる(ex: イベントデータのロストも少量であれば許容する)
- 他の分散SNSと比べてピュアP2Pというまともなパフォーマンスで動かすのが難しいアーキであるが、ユーザ体験のレベルとのトレードオフでどうにかする
- ただし、どこなら落として良いかの見極めはする
- 他の分散SNSと比べてピュアP2Pというまともなパフォーマンスで動かすのが難しいアーキであるが、ユーザ体験のレベルとのトレードオフでどうにかする
- 各サーバはオーバレイNW上で動作させる(NATはグローバルIPを持つサーバによる中継で超える)
-
Nostr のアイデアをベースとする
- 秘密鍵と公開鍵を認証基盤?として利用する、イベントデータのデータ構造、マイクロブログのアプリケーションを実現するための各種Kindの設計など
- ただし、アーキテクチャが異なることから、出来なくなること、逆に可能となることがあるのでそれに合わせてプロトコルの最適化を行う
- 従って、寄せられるところは寄せるものの、それが困難な点などでは互換性が失われた形での設計となる
- goでmeshを使う前提(meshと互換性のあるライブラリが他言語にもあれば良いが現状無い)
- meshでは64bitのIDを各ノードが持っている
- 各ノードはオーバレイNWに参加している全ノードのIDを知っている(最新化まで遅延はあるが、100ノード程度までであれば、2-3秒もあれば大丈夫・・・ではないかと思う)
- 各ユーザが自身のマシンにサーバを立てる。各ユーザが利用するクライアント(プレゼンテーション層を担う何か)はreadもwriteも自身のそれに対して行う(詳細後述)
- 各サーバがオーバレイNW上で64bit ID空間にマップされていることを活用し、DHTベースのKVSなどと同様の考え方で、データのレプリケーション他を行う(詳細後述)
- Nostrプロトコルが可読性と汎用性に重きを置いた結果、マイクロブログシステムでの利用で通信量が比較的大きなものとなったことを踏まえ、データをバイナリフォーマットにシリアライズする、アプリケーション特化での最適化を行うといった方法で通信量を低く抑える
- pullよりもpush(詳細後述)
- 各自が自身のマシンにサーバを立てる。readもwriteもそれに対して行う
- 自宅マシン(つまりグローバルIPを持たないマシン)に立てたりした場合、tailscaleでVPN張ってスマホなんかからはアクセスする想定
- パブリックIPで運用するサーバもいないとオーバレイできる系が成立しない
- => パブリックIPで運用する場合の考慮としてクライアントからのwriteも署名で検証する設定を用意する
- 各サーバは自身がオフラインの間のイベントデータを復帰時に受け取れるよう2つの代理サーバを持つ
- 代理サーバの決定ロジックは後述のレプリカ担当と同様
- pullよりもpush(を基本としてまずは考えてみる)
- ブロードキャストして、followしてる者(もしくは代理役を任されているサーバの依頼元?がfollowしている場合)にだけ受け取ってもらい、他のものには捨ててもらう
- 原則、ブロードキャスト時に受け手はオンラインである想定
- が、サーバがオンラインになった際は未取得のデータを自身の代理サーバから受け取る
- followしていなくとも自身がレプリカ担当であれば、保持する。マスターがオフラインであった場合に問い合わせが来たら返す
- レプリカ担当は2ノードとし、マスターがオフラインの場合は、両方に問い合わせて結果をマージ
- 1番目のレプリカ担当はChordと同様にID空間を円環として見た上でマスターのIDにID空間の最大値の三分の一を足し、それより大きく、かつ一番近いIDを持つノードとする
- 2番目のレプリカ担当は三分の二を足して同様にする
- ブロードキャストして、followしてる者(もしくは代理役を任されているサーバの依頼元?がfollowしている場合)にだけ受け取ってもらい、他のものには捨ててもらう
- 秘密鍵
- クライアントだけが持っていれば良い
- サーバには公開鍵だけを設定しておく。その情報があれば、正しい秘密鍵を持っていないユーザからのwriteは弾けるし、他のサーバが一部のメッセージをユニキャストで飛ばしてくることもできる
- 基本はオンメモリでデータを保持
- が、永続化も定期的にMessagePackで書き出す程度はやる。ひとまず(毎回全ては無駄なので、時刻の範囲でまとめる?そのためには時刻範囲を絞れるデータ構造が必要か)
- メモリに載らない量になってきたら古いデータを捨てていく
- 組み込みDBを導入するのも悪くないが、検索が必要になった際の負荷を考えると、対象となるデータは少なく維持した方が良いのでは、という思いからまずは上ポチの仕様
- サーバ間通信はバイナリで、{kind、コンテント、タグ、公開鍵、署名、イベントID、タイムスタンプ}。バイナリに置き換えている点を除いてNostrと同一だが、イベントIDは64bit(uint64)に変える
- バイナリフォーマットにはひとまずMessagePackを使う。後でProtoBufに切り替えるかもしれない
- サーバを立てる時の面倒が増えるので、通信を over TLSで行うといったことはしない
- meshはアプリケーションで共通に設定したパスワードから共通鍵を生成し暗号化を行えるようだが、パフォーマンス低下の懸念やOSSとの相性の悪さから当該機能は利用しない
- もしやるにしても、E2EEを別途行うといったことになろうが、ブロードキャストと相性が悪いという課題がある
- フォロー
- NW上のノードID一覧(≒ ユーザID一覧)は分かるので、その情報からプロフィールを引いて、ユーザ覧を出すことは作り上は可能
- が、どちらかというと、他のルート(ex: SNS、他のマイクロブログ)で公開鍵を知ってfollowする、というフローの方がお遊びシステム的にはちょうどいい感じもする
- リプライ
- フォローしている者に対してしかできず、当事者間だけにしか見えない
- 対象の投稿のイベントIDは分かるので、それをコンテントに含めて投げる。うまいことネストはできるようにする
- ファボ
- post元のユーザのマスターサーバ及び、レプリカ担当のサーバ個々にユニキャストする
- オンラインになった時はレプリカ担当からイベントデータを受け取る
- ファボした者は対象の投稿にファボしたことのみ分かり総数は分からない。された者は総数が分かる
- ファボしていない者は投稿に関するファボについて何の情報も知り得ない
- プロフ
- 更新したらブロードキャスト
- 新規にfollowした場合はマスターかレプリカから最新のものを取得
- (レプリカの仕組みが実装されるまでの暫定的な仕様として、20%の確率でpostのタグにプロフの最終更新時刻を含め、それにより古いプロフを持っているフォロワーは更新の必要性を知ることができるようにする)
- ノードの参加と離脱
- 離脱に関してはgracefulに行われないことを前提とする
- 参加時
- IDのレンジに応じてレプリカの担当が変わるので、データの委譲を良しなにやる
- 離脱時
- レプリカ担当が離脱してしまった時に、レプリカの数が2つである状態を維持しておく必要がある
- どうやって離脱を検出するか?マスターがレプリカ担当ノードにユニキャストで定期的にハートビートしておく・・か?
- 離脱したノードが戻ってくる可能性も考えると、離脱している時間が一定時間を超えたら、レプリカを増やす、とかにしたいが大変そう
- クライアント
- 自分のサーバと通信
- バイナリでreq/respするRESTをひとまず想定
- (その前段で、サーバサイドレンダリングでクライアントを済ませたり、テキストベースのREST I/Fから作ったりと段階的に進めるとは思う)
- 前述の通りwriteには署名をつける
- readへのレスポンスはイベントデータの形式に沿ったものとしない。少なくとも署名はサーバで検証済みなので除く

現在の進捗は本post下部のTODOリストに記述のような感じ。
TODOリストについても、進捗状況と併せて随時更新しているので、更新を追いかける場合はコチラを参照のこと。
本記事投稿時点(2024/03/03)でできることは、
- サーバ
- NAT透過なオーバレイネットワーク上でメッセージのブロードキャストとユニキャストができる
- サーバ間では決まったフォーマットでのイベントデータと、リクエストデータのやりとりをする
- レスポンスデータというものは存在せず、レスポンスとして要求されたイベントデータを返すのみ
- イベントデータは要求されなくても自発的にブロードキャストする(こちらの通信が主)
- イベントデータの種別としては、投稿データとプロフィールデータが扱えるようになっている
- サーバ間では決まったフォーマットでのイベントデータと、リクエストデータのやりとりをする
- 受信したデータの永続化(再起動しても終了時もしくはクラッシュ時の状態に戻る)
- REST I/Fの提供
- クライアントが呼び出すためのI/Fをいくつか実装済み
- 現状、クライアントから叩けているのは、サーバが保持しているデータのうち、指定した期間に発生したイベントのデータを返すエンドポイントのみ
- ※最終的にはMessagePackあたりでバイナリエンコードしたデータ(を少なくともレスポンスでは)使うようにしたいが、現状はJSONテキストでやりとりをしている
- NAT透過なオーバレイネットワーク上でメッセージのブロードキャストとユニキャストができる
- クライアント
- Flutterでの実装を進行中
- AndroidとWebのプラットフォームで動作することは確認しながら進めている
- 他のiOS向けやデスクトップ環境向けのビルドについては動作すると思われるが確認はしていない
-
Flustr というシンプルなNostr用クライアントをNostrP2P用に修正する形でひとまず開発中。以下ができる
- サーバからのイベントデータの取得
- 取得したイベントデータからの投稿の表示、同様にプロフィール情報の表示
- 投稿は行うUIはあり。ただし、投稿した場合、対応するプロフィールと紐づいた形でTLに表示されるが、サーバには投げずオンメモリで保持するにとどまっている
- Flutterでの実装を進行中
である。
NostrP2P開発のTODO
- 【済】mesh内でIPアドレス(プライベート、グローバル)を区別しているか、区別している場合どのような方法でやっているかを確認する
- ピアごと(コネクションごと?)にisOutboundってな情報を持ってたり、コネクションがsynmetricか否かを区別しているようなので、IPアドレスのレンジとかは見ていなくて、connectivityがどうかだけの情報でうまいことやってくれると思われる
- 【済】プロトコルの細かい形式や署名などは一旦置いといて、メッセージの投稿と受信をして表示ができるものを作る(一方向で良い)
- 【済】起動時の引数回りの仕様を決める
- 【済】main関数からコマンドライン引数をパースする
- 【済】meshの初期化処理の実装
- 【済】 暫定のパケット形式を設計(gob)
- 【済】おおまかなクラス設計とスケルトンの記述(メッセージ書き込みのI/Fと表示のI/Fも含)
- 【済】スケルトンを実装していく
- 【済】動作確認
- 【済】オンディスクのDatastoreの選定
- 【作業中】グローバルTLしかない、and、署名のverification無しの状態で、まあまあの見た目のWebクライアント
まで動くようにする- サーバ側の作りこみ
- 【済】パケットにバージョンを含めて、受信時に自身が対応するものでなければその旨警告を出す
- 【済】受信したパケットは無視するが、可能であればリレーする(要メソッドインタフェース確認)
- 【済】kind 0 相当の情報(プロフィール情報)を扱えるようにする
- 【済】gobでエンコードしたバイナリのgzipでの圧縮率を確認してみる(1イベント)
- => ほとんど圧縮できず。やるにしてもバッファリングなどで複数イベントのデータをまとめてといった方法でなければ効果はなさそう。その場合でも処理コストに見合うレベルかは不明
- 【済】オンメモリでの最低限のデータ保持の仕組みの実装
- 【済】簡易なロギング・リカバリの実装(永続性を持たせる)
- イベントデータを新たに受信した際に、MessagePackでシリアライズしてデータサイズとともに非同期でログファイルに書き出しておく
- 再起動時はログファイルの内容を先頭からリプレイすれば内部状態が元に戻る(ような作りにした)
- 【済】ブロードキャストしたデータが途中でバッファリングされる時のデータのマージにReqフィールドが対応してなかったので対応
- 【済】開発中のテンポラリな機能として他のサーバから持ってるイベントを全て送ってもらう機能をつける
- (誰かが試してくれた時に、何も表示されないとつまらんなあ、となってしまうので)
- 【済】REST I/Fの整備(いくつかは既にあるが、設計を固めて追加必要なものがある)
- 【作業中】クライアント(Flustrフォーク)のNostr I/FのところのNostrP2P I/Fへの書き換え
- 【済】サーバにあるイベントデータの取得と表示
- 【済】投稿した時にTLに表示する(データはクライアント内にあるだけ。永続化もされない)
- 【済】Webビルドで動作させる
- 【作業中】サーバへのwriteの実装(post、プロフィール更新)
- バグ: クライアント起動後に、接続しているサーバ以外に投稿したイベントがクライアントに送信されていない?)
- サーバアドレスを設定できるようにする(要UI)
- イベントデータおよび設定データの永続化
- サーバ側の作りこみ
- TLでの各postに日時とpubkeyを表示する
- プロファイルの更新が(再起動無しだと)反映されないので修正(クライアント)
- 元気玉発行の仕込み(サーバ&クライアント)
- ローカルのデータ数が一定数を下回っていたら起動時に発動
- 自分用サーバからクライアントへの送出数は一定数を上限として設定
- サーバが保持している数が上限数以上あれば他サーバへのリクエストは送らない
- 【ひとまず済】aggregaterがclosedになるとpostを追加できなくなるようなので、そもそもaggregaterが何かを明らかにした上で対処
- ファボの実装(だれからかはpost一覧にpostと同様のUIで表示)
- クライアントのリファクタリング
- フォローの実装(まずはミュートで代用?)
- 署名とその検証の実装(クライアント側で実施してしまう?)
- (この場合、少なくとも、イベントデータのクライアント送信時にイベントIDをNIP準拠の内容にしておく必要あり)
- リプの実装
- クライアントとRESTでやりとりするデータをJSONテキストから、MessagePackバイナリに変更
- (Nostrライブラリを使うために、クライアント内でバイナリ to JSONテキスト の変換が必要そう)
- サーバ-クライアント間の通信の最適化(データ量、モバイルデバイスのアンテナモジュールの電力消費を意識した通信頻度の調整)
- サーバtoクライアントのデータ送信でgzip圧縮をかけてみる
- ブロードキャストのバッファリングでイベントデータがまとまった時にgzip圧縮をかけてみる?
- private keyとpub keyの生成処理の実装(ここまではNostrのものを使ってもらう)
- bech32形式の鍵文字列の読み込み処理の実装

NostrP2Pのトライアル環境的なものができました。
動くとこんな感じの表示がされるはず。
試し方
Webクライアント
クライアントの設定(トライアルネットワークのグローバルTLを見る)
秘密鍵はトライアル用に用意した以下を設定
- nsec1uvktv4u3csltg98caqzux3u0kawxz3mppxjqw40lcytqt52kdslshwr2xp
サーバは以下を指定
注: saveボタンを押してもうまく保存されない場合があるようです。入力エリアの上に current server address という表示とともに入力内容が表示されていないと設定が反映されていない状態なので、表示されない場合は何度か押してみてください。
書き込みもできないとつまらないので今は投稿なども開けてあります
/*
投稿は本来自分用のサーバに行う想定のシステムのためデモサーバに接続するだけだとできません。
(できないようにガードをかけてあります、というか、データの参照も本来は自分用のサーバを介して行う想定ですが、そっちはデモなので開けてある、ということです)
自分用のサーバを立てて、そのサーバを上のサーバに接続する形でオーバレイNWに参加させ、その上で、自分用サーバにクライアントからアクセスすれば投稿も可能になります。
*/
トライアルネットワークのグローバルTLに投稿する
おひとり様(自分用)サーバを立てる
サーバの立て方はリポジトリにあるExamplesでの内容と同じです。
コマンドラインオプションの中で -b <ブートストラップサーバのアドレス> としているところで、 ryogrid.net:8888を指定すればOKです。
サーバのバイナリは以下にビルド済みのものを置いてあります。
動作させたいプラットフォーム用のバイナリが無い場合は、お手数ですが自前でのビルドをお願いします 〇刀乙
なお、秘密鍵と公開鍵はトライアル用のものとは別のものを使うことになります。
鍵ペアは以下で生成できます。
- $ .¥nostrp2p_server.exe genkey
- Windowsの場合の例
公開鍵を指定する -p オプションにはnpub形式のものを指定して下さい。
おひとり様サーバにクライアントからアクセスする
- 利用するクライアントによって少し方法が変わります
- なお、アクセスするポート番号はいずれでも - l オプションで指定したもの + 1です
- 例えば、-l オプションは省略可能ですが、その場合は 127.0.0.1:20000 を指定したものと見なされるので、クライアントで指定する際のポート番号は20001になります
- なお、アクセスするポート番号はいずれでも - l オプションで指定したもの + 1です
- 以降は、サーバがグローバルIPアドレスを持つマシンではなく、プライベートネットワーク内に立てられる前提での記述になります
- 最終的にはイベントデータの投稿も、データの取得リクエスト(デモサーバでは通るやつ)も署名を検証することで、サーバの持ち主(?)しかREST I/Fを利用できないようにする計画
- しかし、現在そこが未実装であるため、インターネットからアクセス可能な形で立てると、アドレスがばれた時に他のユーザにも利用されてしまう状態です
- 従って、現時点ではプライベートネットワークに立ててもらわざるをえない状況であったりします
- トライアル用のWebクライアントを使う
- トライアル用といっても特に制限などがあるわけではないのでこれを使う手があります
- ただし、Webブラウザのセキュリティの制限から、サーバ側のREST I/F が暗号化されていない(over TLSでない)場合、通信がブロックされて、動作しません
- 従って、over TLS化、言い換えれば元はHTTPで開いているREST I/Fの口をHTTPS化する必要があります
- 他にも方法はあると思いますが、ひとまずこれをプライベートネットワークでも簡単に行う方法の1つとしてtailscale(無料VPN構築ツール・サービス)のリバースプロキシ機能というものを使う方法があります
- "Tailscale 組み込みのリバースプロキシでVPN向けWebアプリをHTTPS対応する - DevelopersIO"
- これを利用することでサーバを立てたマシンや、VPNに参加している端末からデモクライアントを利用して自身のおひとり様サーバにアクセス可能になります
- 細かい話をすっとばすと、tailscaleを導入して上の紹介記事を参照しつつ "/" へのアクセスを "http://127.0.0.0:<上述のポート番号>/" にマッピングして、tailscaleのDNSがサーバを立てたマシンに割り振った "うんたら.tailed数字.ts.net" というアドレスを使ってクライアントからアクセスすればOKです
- URLはこうなります -> https://うんたら.tailed数字.ts.net/
- ネイティブクライアントを使う
- 以下に置いてある各種ネイティブクライアントを用いる場合は自分用サーバのREST I/Fの口はHTTPのままでいけます(Webブラウザのような制限が無いので)
- https://github.com/ryogrid/flustr-for-nosp2p/releases/tag/latest