Skip to content

Instantly share code, notes, and snippets.

@ryogrid
Last active June 21, 2024 13:27
Show Gist options
  • Save ryogrid/0ba0d825c3bb840dffa519c5ab91d4ff to your computer and use it in GitHub Desktop.
Save ryogrid/0ba0d825c3bb840dffa519c5ab91d4ff to your computer and use it in GitHub Desktop.
みんなで頑張る分散ピュアP2Pマイクロブログシステム(NostrP2p)

正直、精査していないので普通に穴があるかもしれない。
GitHub Repo

  • コンセプト
    • 利用者皆の貢献により構成されるシステム
      • 課題感: 既存の分散SNS(Mastdon、Nostr、Bluesky、etc..)は言うて、サーバ運用者にかかる負荷が高く、その割に見返りもない
  • その他のポイント
    • gossipプロトコルによるブロードキャストを軸にしたシステム(メッセージングはweaveworks/mesh ライブラリが担う)
    • パフォーマンスやデータの一貫性より実装の容易さとシンプルさに重点を置く
      • 結局のところは省工数にしたいという理由に落ちるかもしれないが、この手のシステムで複雑な仕組みを入れると安定して動くようにするのが大変
      • 上の理由からDHTなどの構造化の仕組みは(ひとまず)採用しない
    • 全体的にファジーにやる(ex: イベントデータのロストも少量であれば許容する)
      • 他の分散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番目のレプリカ担当は三分の二を足して同様にする
  • 秘密鍵
    • クライアントだけが持っていれば良い
    • サーバには公開鍵だけを設定しておく。その情報があれば、正しい秘密鍵を持っていないユーザからのwriteは弾けるし、他のサーバが一部のメッセージをユニキャストで飛ばしてくることもできる
    • なお、秘密鍵・公開鍵はNostrのものがそのまま使えるようにする(HEX形式)
  • 基本はオンメモリでデータを保持
    • が、永続化も定期的にgobで書き出す程度はやる。ひとまず(毎回全ては無駄なので、時刻の範囲でまとめる?そのためには時刻範囲を絞れるデータ構造が必要か)
    • メモリに載らない量になってきたら古いデータを捨てていく
      • 組み込み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へのレスポンスはイベントデータの形式に沿ったものとしない。少なくとも署名はサーバで検証済みなので除く

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ビルドで動作させる
      • 【済】バグ: クライアント起動後に、接続しているサーバ以外に投稿したイベントがクライアントに送信されていない?)
        • => 勘違いだった
      • 【済】REST I/Fのリクエスト形式の変更
        • 【済】サーバ側
        • 【済】クライアント側 <03/07>
      • 【済】サーバへのwriteの実装(署名やらもろもろは空で送信)
        • 【済】post投稿 <03/07>
      • 【済】接続先サーバを設定できるようにする <03/08>
  • 【済】Readだけできるデモ環境をbootstrapサーバベースで用意する <03/09>
    • 【済】write禁止オプションがちゃんと働くようにする
    • 【済】updateProfileのRESTが再び通るようにサーバを修正して、デモ用のやつのプロフィールを設定 <03/09>
    • 【済】write禁止で弾かれた時にクライアントの動作に問題が出ないか確認
    • 【済】クライアントとサーバのコードを公開中のをmasterという運用にして、タグ付けしてビルドを作成
      • 【済】Webクライアントを用意する
    • 【済】作業ブランチに張られているリンクをリポジトリ直に書き換える
    • 【ダメだった】グローバルIPに置いてwebビルドでローカルのサーバで接続できるか確認
      • (vercelでHTTPS化して配置したクライアントから、tailscaleの便利機能でHTTPS化したローカルのサーバへは接続できた)
  • 【ひとまず済】aggregaterがclosedになるとpostを追加できなくなるようなので、そもそもaggregaterが何かを明らかにした上で対処
  • 【済】Android、Windows向けのクライアントビルドを用意してReleaseに置く
  • 【済】デモ環境に接続できるサーバのビルドを用意してReleaseに置く
  • 【済】自分用サーバを立ててトライアル環境に投稿をするまでの手順をまとめる
  • 【済】デモの説明を英語で書く(リポジトリの中にマークダウンで置く)<03/11>
  • 【済】Android版クライアントでAggregaterがpost時にcloseしてしまっている現象が起きているので調査・修正<03/13>
  • 【済】TLでの各postに日時とpubkeyを表示する <3/14>
  • 【済】プロフィール更新(クライアント)<3/15>
  • 【済】プロファイルの更新が(再起動無しだと)反映されないので修正(クライアント)<3/16>
  • 【済】一回プロフィール更新していても上書きできるようにする<3/17>
  • 【済】REQ時のfilterのフォーマットをNostrと同じにする<3/17>
  • 【済】イベントデータの形式をNostrと一致させる(少なくとも変換をすれば同じものを生成可能とする)<3/18>
  • 【一応なんか動いた】YonleさんのWS <-> REST のブリッジを通して動くようにする?<3/19>
    • https://github.com/Yonle/nhttp-adapter
    • noStrudelはnhttp-adapterでうまく動きそう
    • イベントIDを64bit uintにまるめてしまっているのをどうにかする必要あり(他のサーバに渡すことを考えても)
    • (NostrP2PのNWにNostrのイベントを投げ込むとかしても面白いかもしれない)
  • 【済】WS<->RESTのブリッジでNostrクライアント互換(noStrudel)にするのを実装済のkindの範囲でまともに動くようにする <3/20>
  • 【済】NostrP2Pのクライアント向けI/FをWS<->RESTブリッジ可にした変更への、NostrP2P用Flutter製クライアントの対応 <3/21>
  • 【済】デモ第3弾の記事の英訳とgist化<3/23>
  • 【ひとまず済】クライアントのリファクタリング<3/23>
  • 【済】(自身含め)プロフィールデータが更新された時に、アプリを再起動しないと反映されない不具合の修正<3/24>
  • 【最低は済】ファボの実装<3/24>
  • 【最低は済】複数ファボがうまくいかない現象の対応(特定のpubkeyのユーザがreactionを受けられない。多分サーバ側のバグ)<3/24>
  • 【済】サーバ側のノードID回りの整理と上の複数ファボがうまくいかない現象の一部残ったところの対処 <3/25>
  • 【済】ファボがだれからかの表示(カードの右下に表示)<3/26>
  • 【済】個別のプロフィール情報取得(クライアント、サーバ)<3/27>
  • 【ひとまず済】フォローの実装
    • 【済】クライアントののfollow情報を管理するためのProviderやらの用意 <4/1>
    • 【済】各ユーザのプロフィール表示のページを用意してフォローボタンを置く<4/1>
    • 【済】各ユーザのフォローおよびフォロー解除をユーザページで行えるようにする and それらの情報を管理する<4/1>
    • 【済】サーバ側
      • 【済】フォローリストのsubmit、reqのI/Fを提供する(例のごとくその場で返せるとは限らない)<4/1>
    • 【済】タイムライン表示をglobal と followingで切り替えられるようにする<4/2>
  • 【済】meshライブラリと密結合になり過ぎているので、トランスポートを他のものに変えたりできるようinterface使って分離した<4/7
  • 【済】ユニキャストしないといけない種のeventをクライアントから受信した時に、送信先がオフラインだった場合の考慮の実装 <4/7>
    • (再送データキューの永続化も含めて済)
  • 【済】再送処理が漏れていたので実装<4/7>
  • 【済】上の再送処理の動作確認<4/8>
  • 【済】通知表示画面の実装(リアクションの表示は実装済み)<4/13>
  • 【済】リプの実装(クライアント)
    • 【済】リプのポスト<4/13>
    • 【済】リプのTLと通知ページでの表示<4/13>
    • 【済】リプのスレッド表示<4/13>
  • 【済】メンションの実装(クライアント)<4/15>
    • (宛先の指定をパースするの面倒なので、ユーザページに投稿ボタンを置くような感じで)
  • 【済】quote repostやrepostをクライアントから受信した場合の対応(サーバ側)<4/18>
  • 【済】quote repostとrepostの実装(クライアント)<4/21>
    • 【済】EventViewにrepostとquote repost用のボタンを実装する<4/21>
      • (この機にリファクタリングできたらしたい。継承かメソッド呼び出しでの切り出しで他の類似コンポーネントとの統合も?)
    • 【済】postがquote repostおよびrepost時の際のサーバに投げるまでの処理を実装 <4/20>
    • 【済】repostなEventを受信した際の処理を表示するところまで実装<4/21>
    • 【済】quote repostなEventを受信した際の処理を表示するところまで実装<4/21>
  • 【済】元々接続していなかったノード間で(想定通り)自動的に接続が確立されることを確認する<4/21>
  • 【済】Np2pEvent型のTagsフィールドの型を map[string][]interface{} から [][]string に変える
  • 【済】署名の検証の実装(サーバ)<4/26>
  • 【中止】サーバ間通信とログのシリアライザをMessagePackからProtobufに切り替える <4/28>
    • => 失うものの割にさして差が無いので中止(イベントデータで248byte -> 203byte とか)
  • 【済】iOSブラウザでも表示できるWebビルドを作れるようにする<4/28>
    • => 無害なので放置していたexceptionを潰して、profileビルドにするようにしたら表示されるようになった
  • 【済】postする時に改行できるようにする(クライアント)<4/29>
  • 【済】サーバでメモリ可能使用量を設定できるようにして溢れるものは削除していく処理を実装<5/1>
    • プロフィール情報とフォロー情報はログ削除や、kind40000で特別扱いされるようにする
      • => 組み込みDB(NutsDB)の導入で丸っと解決
  • 【済】エンディアンの考慮(サーバ)<5/2>
  • 【済】private keyとpub keyの生成処理の実装<5/3>
  • 【済】サーバ起動時に指定するpubkeyをnpub形式にする<5/4>
  • 【済】クライアントに表示されるpukeyの先頭文字列をnpub形式の先頭にする<5/4>
  • 【済】ListViewで表示するカード数に上限を設ける<5/4>
  • 【済】一回目のkind40000を期間指定でなく、limitによる個数指定で行うようにする<5/5>
  • 【済】DB内の一定量より古いデータを削除する(followリストとprofileは除外)<5/6>
  • 【済】followリストとprofileをクライアントに返す際に一定期間以上経過したデータであれば持ち主に最新を取得するためのリクエストを送信する(サーバ)<5/11>
  • 【済】期限を過ぎた再送レコードのキューからの削除(サーバ)<5/12>
  • 【済】プロフィールもしくは、followリストを受信した際に、古いものを保持していた場合は削除する(サーバ)<5/12>
  • 【済】リンク文字列に別タブ・ウィンドウで開く形にハイパーリンクを設定する(クライアント)<5/14>
  • 【済】ずっと起動しているサーバで再送スレッド動作時の出力が一個しか出てない???(不具合の可能性高し)<5/18>
  • 【済】"received unknown kind event: 6" という出力についての調査<5/18>
  • サーバ-クライアント間のREST I/Fでやりとりする際のイベントデータのサイズ最適化
    • 【済】データ形式をJSONテキストからMessagePackバイナリに変更(/reqエンドポイントのレスポンス)<5/19>
      • (クライアントは署名の検証を行う必要がないので、署名データは空にして返す)
    • 【済】HTTPレイヤでのgzip圧縮(/reqエンポイントのレスポンス)<5/19>
    • /publish エンドポイントのリクエストについても同様に最適化する
  • 【済】クライアントtoサーバのHTTPリクエストのコネクションがHTTP/1.1仕様の範囲で使い回されるようにする<5/20>
  • クライアントでのプロフィールデータとフォローデータの一定期間のキャッシュ(アプリ終了後も残る形で)
  • kind40000という独自kindを、kind無指定のイベントデータ要求の形に置き換える形で排除する
  • クライアントで保持するイベントデータに上限を設ける
  • notificationの画面でmentionの通知の場合の表示を to me とかそんな感じに変える
  • replyやmentionされているpostがreactionも受けている場合に、通知画面でreactionの数が表示されてしまうので修正
  • ユーザページに、か、ユーザページから飛んだページでfollowしているユーザ一覧を見られて、そこからfollowもできるようにする
  • フルのpubkeyを入力するとフォローできるUIを用意?
  • プロフィール更新の時に更新されたことを分かりやすくする
  • イベントデータ(少なくともプロフィールデータ)の永続化(クライアント)
  • クライアントとRESTでやりとりするデータをJSONテキストから、MessagePackバイナリに変更
    • (Nostrライブラリを使うために、クライアント内でバイナリ to JSONテキスト の変換が必要そう)
  • /*
  • 元気玉発行の仕込み(サーバ&クライアント)
    • ローカルのデータ数が一定数を下回っていたら起動時に発動
    • 自分用サーバからクライアントへの送出数は一定数を上限として設定
    • サーバが保持している数が上限数以上あれば他サーバへのリクエストは送らない
  • */
  • 公開鍵は圧縮ができたはず?同じように署名も圧縮できる?
@ryogrid
Copy link
Author

ryogrid commented Feb 5, 2024

  • ノード離脱が起きた場合、レプリカを増やすことがあるが、あるノードA(離脱したノードより大きいIDで一番近いIDを持っていたノード)にレプリカのデータを渡すにしても、今の設計だと、離脱したノードが複数のマスターのレプリカ担当な場合がある
    • => それらのマスターないし、それらのレプリカ担当らのリストを作って、各ノードからレプリカデータを受け取るというのは結構しんどい気がする。そもそも、データをもらう先のノードリストが簡単に作れるだろうか?
  • 系に対する攻撃(無意味なイベントメッセージを送信しまくるなど)には脆弱と思われるが、対処についてはひとまず考えない。もし必要になったら後付けでも多分・・・どうにかなる
  • 他人の公開鍵を指定してサーバが立てられた場合、今の設計だとオーバレイネットワーク上に同一IDのサーバが複数存在する状態になり、当該IDに向けたメッセージが本来の鍵の持ち主に届かない可能性がある。(meshの実装依存だが毎回両方に届くことはない気がする)
    • 内部的には、毎回、公開鍵の値をランダムに±2 程度 の範囲で変更したIDでネットワークに参加する、とかである程度対処可能かもしれない
      • meshだとNW上のノードのIDは把握できるので、ユニキャストの時は送信したい公開鍵とほぼ一致しているノードがいれば全てに送信してしまえば良い
      • ブロードキャストを受信した際に無視するか受信するかも同じロジックで対処可能
      • ただ、いずれにしても単純な完全一致で判断ができなくなるので、処理負荷は若干増える可能性あり
  • 全ユーザないし、フォローしているユーザの(フルの)公開鍵を各サーバが保持する形になるようにすれば、イベントデータに含める公開鍵は下位64bit程度に絞っても問題ないのでは?
    • フルの公開鍵が分らない場合は、分かっている下位64bitはノードIDなので、その情報でフルの公開鍵を持っているはずのノードに要求をすればよい
      • なお、この方法は悪意のある者が下位64bitが一致する鍵ペアを生成しようとしても、それが困難であろうことを前提としている(が、どの程度の難度なのかは正確には不明)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment