モバイルモニター
既定では、ボードに接続できるのは同じマシンからだけです。モバイルモニターは、スマホが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はローカルのリスナーにだけあります。
用途ごとに二種類の状態を持ち、アクセス範囲を分離しています。
| 状態 | 含むもの | 含まないもの |
|---|---|---|
MonitorState | reader・changedと表示設定の購読・許可するHost・独自の資産。 | ボードのトークン。 |
| モニターの書き込みrouter | 回答の経路、表示設定の保存、それらの書き込みのガード。 | PCのボードのトークン。 |
接続
接続は、サーバーの状態が持つMonitorConnectionsという一つの監督役が管理します。保存した設定、トークン、ライフサイクルの状態、動作中のトンネルを持ちます。
| 状態 | 意味 |
|---|---|
disabled | 有効ではない。既定。 |
needs_config | 保存した設定は有効だがトークンがなく、何も開始していない。 |
starting | トンネルを開き、ポリシーを適用している。 |
ready | 公開アドレスが確定し、入口が要求を受け付ける。 |
reconnecting | APIと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を拒否します)、サインインも許可リストも拒否のルールも、アプリが信頼するヘッダーもありません。要求は、次の三つの層を順に通った場合だけモニターに届きます。
- Host。 入口は、トンネルが準備済みで、
Hostがngrokの返した公開HTTPSアドレスとちょうど一致する(Hostヘッダーが一つで、ポートも付かない)要求だけを受け付けます。それ以外は理由を示さない同じ403を返します。ループバックのポートを開いたブラウザはHostがループバックのアドレスなので、これに含まれます。転送ヘッダーやメソッド上書きのヘッダーは信頼しません。Hostの確認は、要求がngrok経由で届いたことをアプリに証明しません。防げないことを参照してください。 - 合言葉と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トークンを返します。間違っていれば403unlock_failedで、どれだけ近かったか、残り何回かは示しません。 - 制限。 連続5回目の間違いまでは403です。それ以降は、正しい合言葉を含むすべての試行が429
unlock_lockedになり、何も発行しません。新しく起動し直したときだけ、新しい合言葉と数え直した回数になります。ヘッダーや本文の不備(Originがない、JSONでない本文)で拒否された要求は推測ではないため、数えません。 - session。
GET /monitor/api/sessionは、有効なsessionとそのCSRFトークンを知らせるか、401session_requiredを返すだけで、何も始めません。そのほかの/monitor/api/の経路は有効なsessionが必要で、なければ401session_requiredです。イベントストリームは、sessionの終了、起動の終了、5分の経過で終わります(ページは再接続します)。
- 合言葉。 起動のたびに、OSの乱数源から4桁の合言葉(
- 書き込みのガード。 回答、表示設定の保存(
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に保存しません。

