本文へ移動

モバイルモニター ​

既定では、ボードに接続できるのは同じマシンからだけです。モバイルモニターは、スマホがngrokのトンネルを通して開く、読み取り中心の別のアプリです。トンネルはserveプロセスの中にあり、ボードの設定かagboard serve --monitor ngrokで操作します。利用者向けの設定手順はモバイルモニターのガイド、経路はモニターのルートにあります。保護の概要はセキュリティモデルにまとめています。

全体の構成 ​

  • ngrokのRust SDKをagboardにリンクしています。外部のngrokプログラムやサイドカーはありません。src/server/monitor_connection/の監督役が、ドメインもTraffic Policyも指定しないHTTPエンドポイントを一つ開き、モニターの入口へ転送します。公開アドレスはngrokが選びます。無料のngrokプランはTraffic Policyの操作を拒否するため、Traffic Policyは送りません。
  • モニターの入口は、接続が起動と停止を行う専用のループバックのリスナーです。ローカルのリスナーと同じserveプロセスで動き、ライフサイクルのガード、FileStoreの索引とreader、監視、changedのbroadcastを共有します。serve.jsonにはローカルのポートだけを残すので、CLI・Hook・waitは影響を受けません。
  • プロジェクトのドキュメントのルートは、ボードのルーターにだけあります。モニターの入口にはないので、ngrok経由でドキュメントに届くことはありません。
  • ローカルのリスナーとモニターの入口は別の面です。ローカルのリスナーは自身のHost・Origin・トークンの確認を保ち、モニターの入口がPCのボードを配信することはありません。接続設定のAPIはローカルのリスナーにだけあります。

用途ごとに二種類の状態を持ち、アクセス範囲を分離しています。

状態含むもの含まないもの
MonitorStatereader・changedと表示設定の購読・許可するHost・独自の資産。ボードのトークン。
モニターの書き込みrouter回答の経路、表示設定の保存、それらの書き込みのガード。PCのボードのトークン。

接続 ​

接続は、サーバーの状態が持つMonitorConnectionsという一つの監督役が管理します。保存した設定、トークン、ライフサイクルの状態、動作中のトンネルを持ちます。

状態意味
disabled有効ではない。既定。
needs_config保存した設定は有効だがトークンがなく、何も開始していない。
startingトンネルを開き、ポリシーを適用している。
ready公開アドレスが確定し、入口が要求を受け付ける。
reconnectingAPIとPCのダイアログが理解する状態の一つ。監督役はこの状態にしない。ngrok SDKの転送には切断の信号がないため、失ったトンネルの復旧を保証するのは、停止して開始し直すことだけ。
failed直近の開始に失敗した。failureにコードが入る。
stoppingトンネルとsessionを閉じている。
  • 開始。 明示した開始(PCのダイアログの保存して接続か--monitor ngrok)だけがトンネルを開きます。ダイアログを開く、下書きを保存するだけでは開始しません。開始は、先に設定を有効として保存します。保存に失敗したら何も開始しません。開始要求で入力したトークンが優先され、なければNGROK_AUTHTOKEN、どちらもないときだけ、このPCの安全な保管場所に保存したトークンを使います。
  • 復元。 serveの起動時に保存した設定が有効なら、環境変数かこのPCの安全な保管場所からトークンを使える場合だけトンネルを開始します。使えなければneeds_configにしてPCの操作を待ちます。設定ファイルを読めなければconfig_unreadableでfailedにし、PCの入口は維持します。コマンドラインの開始は保存した設定より優先され、失敗するとserveは終了コード1で終わります。
  • 開始のやり直し。 トンネルが動いている間の開始(トークン再設定のあとのPCのダイアログの保存して接続など)は、古いトンネルを閉じ、新しい合言葉を作り、古いsessionとイベントストリームを終えてから、新しいトンネルをreadyにします。同じ設定をもう一度保存しても何も変わりません。
  • 停止。 停止はトンネルを閉じ、入口を閉じて開いているイベントストリームを終え、新しい回答を拒否し、設定を無効として保存します。設定ファイルは原子的に書き込み、合言葉・session・トークンをディスクへ書くことはありません。

保存先 ​

内容保存先
enabled、revision<データ領域>/.internal/monitor-connection.json。所有者だけが読み書きできる、アプリの内部ファイル。共有ファイルを参照。
ngrokのトークン利用者が保存しない限り、メモリーだけ。保存したトークンは、このPCの安全な保管場所であるOSの資格情報ストア(service agent-board、account ngrok-authtoken)。
スマホの合言葉とその間違いの回数、アプリsession、CSRFトークン一回の起動の間、メモリーだけ。
言語とカラーテーマ<データ領域>/.internal/preferences.json。PCまたはモニターの保存で書き込む。共有ファイルを参照。

NGROK_AUTHTOKENと安全な保管場所は必要になったときに読むため、トークンが要らない起動では安全な保管場所に触れません。安全な保管場所がない、または拒否する場合は、keychainAvailableがfalseになり、保存はkeychain_unavailableで拒否され、利用者は毎回トークンを入力します。

認証と書き込みの保護 ​

Edgeがアプリのために行うことはありません。エンドポイントはTraffic Policyなしで開くため(無料のngrokプランはTraffic Policyを拒否します)、サインインも許可リストも拒否のルールも、アプリが信頼するヘッダーもありません。要求は、次の三つの層を順に通った場合だけモニターに届きます。

  1. Host。 入口は、トンネルが準備済みで、Hostがngrokの返した公開HTTPSアドレスとちょうど一致する(Hostヘッダーが一つで、ポートも付かない)要求だけを受け付けます。それ以外は理由を示さない同じ403を返します。ループバックのポートを開いたブラウザはHostがループバックのアドレスなので、これに含まれます。転送ヘッダーやメソッド上書きのヘッダーは信頼しません。Hostの確認は、要求がngrok経由で届いたことをアプリに証明しません。防げないことを参照してください。
  2. 合言葉とsession。 ページと資産には最初の層だけが必要です。それ以外はsessionなしでは配信せず、sessionはPOST /monitor/api/unlockでだけ始まります。
    • 合言葉。 起動のたびに、OSの乱数源から4桁の合言葉(0000〜9999)を偏りなく作り、連続した間違いの回数とともにメモリーに置きます。PCがこれを見るのは、接続がreadyの間のローカルの状態APIと、serveの起動行だけです。ファイル、ログ、serve.json、ページ、エラー、スマホ向けの応答には一切書きません。
    • unlock。 本文はJSONの{"code":"1234"}で、公開Originとの完全一致とSec-Fetch-Site: same-originが必要です。まだsessionがないため、CSRFトークンは要りません。コードは定時間で比較します。正しければ、Cookieagboard_ngrok_session(Secure; HttpOnly; SameSite=Lax; Path=/monitor/、最長12時間。この起動と公開アドレスに結び付く)を発行し、回数を0に戻し、CSRFトークンを返します。間違っていれば403 unlock_failedで、どれだけ近かったか、残り何回かは示しません。
    • 制限。 連続5回目の間違いまでは403です。それ以降は、正しい合言葉を含むすべての試行が429 unlock_lockedになり、何も発行しません。新しく起動し直したときだけ、新しい合言葉と数え直した回数になります。ヘッダーや本文の不備(Originがない、JSONでない本文)で拒否された要求は推測ではないため、数えません。
    • session。 GET /monitor/api/sessionは、有効なsessionとそのCSRFトークンを知らせるか、401 session_requiredを返すだけで、何も始めません。そのほかの/monitor/api/の経路は有効なsessionが必要で、なければ401 session_requiredです。イベントストリームは、sessionの終了、起動の終了、5分の経過で終わります(ページは再接続します)。
  3. 書き込みのガード。 回答、表示設定の保存(PUT /monitor/api/preferences)、POST /monitor/api/disconnectには、公開Originとの完全一致、Sec-Fetch-Site: same-origin、X-Agboard-CsrfのCSRFトークンも必要です。

4桁の合言葉は、意図的に小さな秘密です。10,000通りしかなく、5回までの制限と、起動ごとに変わる合言葉で守ります。アカウントでのサインインより弱い守りです。防げないことはセキュリティモデルにまとめています。

モニターの回答は、そのあとモニターの書き込みrouterのガードを通ります。入口は自身の確認をすべて通った後でだけガードの内部トークンを付けるので、ページがそれを持つことはありません。FileStoreは、ロックの中でPending・依頼ID・選択肢を再確認します。受け付けるのは、未着手か進行中のタスクの現在の依頼だけです。Done・回答済み・アーカイブ済みなどの依頼は拒否します。

表示設定の保存も、入口の確認と同じ書き込みのガードを通ります。そのあと共有の表示設定の処理に届きます。受け付けるのはlocaleとthemeだけで、PCのpreferences.jsonに書き、開いているボードとモニターのイベントストリームにpreferencesイベントを送ります。このモニターの経路以外で、データや設定を変えるものはありません。

トークン・合言葉・sessionの値が、静的なJSやCSS・manifest・APIの応答・serve.json・ログに現れることはありません。すべてのHTMLと機密性のあるAPIは、Cache-Control: no-store・Referrer-Policy: no-referrer・X-Content-Type-Options: nosniffと、スクリプトと接続を自分自身に限りframe-ancestors 'none'を持つCSPを返します。内容で名前が決まりトークンを含まないスクリプトとスタイルシートは、代わりにimmutableでキャッシュします。

モニターのアプリ ​

  • モニター(web/monitor.html・web/src/app/entry/monitor.tsx・web/vite.monitor.config.ts)はweb/src/pages/monitor/App.tsxだけを起点にします。PCのApp.tsx・DetailPanel・エディター・汎用の書き込みクライアントはimportしません。独自のmanifestとともにweb/dist/monitor/にビルドし、src/server/monitor/assets.rsはそのmanifestだけを配信します。Mermaidには、ボードのビルドのchunk-mermaid.coreチャンクと、そこからimportされるすべてのチャンクを使います。これらはweb/dist/monitor/にそのままコピーし、一つでもweb/src/のモジュールを含めばビルドを止めます。実行ファイルには同じバイト列を一度だけ埋め込みます。
  • アドレスがプロジェクトを指定します: /monitor/?project=<ProjectId>。プロジェクトがない、形式が正しくない、または存在しない場合はエラー画面を表示し、サーバーからは何も読みません。モニターにはプロジェクトの一覧も切り替えもありません。スマホで別のプロジェクトを見るには、そのプロジェクトのアドレスを使います。
  • 共通の表示部品(ヘッダー、タスクの内容、状態のラベル、テーマ、i18n、閲覧専用のMarkdown・受入条件・履歴)は再利用します。タスクの見出しから、本文・受入条件・履歴・エピックのタブを持つ閲覧専用のドロワーを開き、回答フォームはタスク上に置きます。ドロワーとそのMarkdown表示は別のチャンクに分け、一覧を表示した直後に取得します。そのため、初回の読み込みは一覧と回答フォームだけで済みます。
  • モニターのクライアントは、Summary・Detail・イベント、所有する画像の読み取り、回答のPOST一つ、表示設定の読み取りと保存、そしてsessionの呼び出しを持ちます。ページはトークンを持ちません。読み込み時にGET /monitor/api/sessionでsessionがあるかを確かめます。なければページは合言葉を入力を表示し、何も読み取りません。合言葉を送るのは人が送信したときだけで、POST /monitor/api/unlockに送り、返ってきたCSRFトークンをメモリーに置いて、一つの回答POSTでX-Agboard-Csrfとして送ります。sessionがあとから終わった場合(期限切れ、または接続の開始し直し)は、合言葉の画面に戻ります。間違った合言葉では、入力した数字を残し、回数は表示しません。5回目の後はフォームを閉じます。応答がモニターのJSONでない場合、ページは合言葉を求めずに、接続できないものとして再試行します。表示設定の保存は、回答と同じ方法でCSRFトークンを送ります。回答と表示設定の保存以外に、PATCH・PUT・DELETEの関数はありません。
  • PCとモニターの回答は、FileStoreの一つの保存処理を共有します。ロックの中での依頼の照合、requestAnswersと履歴の項目、索引の更新、通知です。回答に成功したタスクは、そのレーンのIdleになります。
  • ドロワーはprojectId・taskId・epicIdと世代番号で対象を固定します。各タブは開いたときに、自分が表示するDetailのセクションを読みます。本文と受入条件はdocument、履歴はhistory、エピックはエピックの本文を読みます。タブごとのスクロール位置を保ち、遅れた応答や範囲外の応答は無視します。Detailの読み取りに失敗しても再試行でき、一覧や回答を妨げません。

取得と復帰 ​

モニターは、定期的なポーリングを使わずに最新のデータを表示します。最新と確認できない場合は、データが古い可能性を明示します。

  • 一つのプロジェクト。 アドレスは一つのプロジェクトを指します。モニターはページを開いたときにそのSummaryを読み、changedイベントのたびに読み直します。Detailのセクションは、ドロワーが表示するときにだけ読みます。ストリームは?project=を付けて開くので、ほかのプロジェクトのイベントはページに届きません。
  • データの不備。 SummaryのProblemsは表示へ渡します。HTTP 200でも問題が一つでもあればpartialにします。サマリーは「—」にし、読めたタスクだけを並べ、データに不備があると案内します。外したタスクを0件として数えることはありません。データの不備だけでは、再試行・ポーリング・再接続をしません。
  • 鮮度はloading・syncing・fresh・stale・partial・errorのいずれかです。表示がfreshになるのは、SSEのreadyが届き、Summaryを問題なく取得し、heartbeatが期限内のときだけで、onopenだけでは足りません。SSEが止まった状態で手動のGETが成功したら、取得済みだが自動更新は止まっていると表示します。最後の全体取得の時刻は、問題のない完全な取得でだけ進めます。
  • 一つの取得処理を、手動の更新・SSEの通知・ページへの復帰が共有します。AbortControllerと世代番号で遅れた応答を捨て、取得中のchangedはdirtyフラグで一回の後続の取得にまとめます。
  • SSE。 モニターはブラウザのEventSourceを使います。SSEはchangedを中継し、モニターのビルドIDを含むready({"build":"…"})を送り、15秒ごとにheartbeat(data {})を送ります。表示中に45秒何も届かなければ接続を古い状態にして再接続します。heartbeatでスナップショットを取り直すことはありません。
  • ページの表示状態。 非表示の間はSSEとheartbeatの判定を止めます。表示に戻るとすぐに取り直して再接続し、readyによる重複をまとめます。GETは10秒でタイムアウトします。再試行は1・2・5・10・30秒と間隔を延ばし、非表示の間は止めます。失敗が続いたら、取り直し続けるのではなく、syncingかstaleに戻して復旧のタイマーに任せます。
  • 並び順。 agentStatusChangedAtは稼働状態の変更時刻、taskStatusChangedAtはDoneの並び順に使います。相対時刻は1分ごとに更新してよいですが、そのタイマーで取り直すことはありません。
  • セットアップの完了前は読み込み中を表示し、プロジェクトやタスクが0件であることとネットワークの失敗を区別します。取得した本文や件数は、localStorageやService Workerに保存しません。