
前回のエピソードからの続き
EP1 では RDP を捨てた理由を説明し、EP2 では FastAPI + WebSocket + React アーキテクチャを設計しました。サーバーは稼働し、WebSocket パイプラインも接続済みです。いよいよ本題に入ります — 誰が接続しているかをどう検証し、Claude Code ブラウザターミナルをどう安全に公開するか?
今回のエピソードでは、JWT + bcrypt 認証から TOTP 2FA、Tailscale 自動ログイン、そして xterm.js + PTY ベースの Claude Code ブラウザターミナルストリーミングまで、すべてカバーします。
なぜ認証が必要か — Tailscale だけでは不十分なのか?
Tailscale VPN の中にいるなら、認証なしで開放しても問題ないのでは? 結論から言えば — それはできません。
Tailscale はネットワークレベルのアクセス制御です。誰がネットワークに参加できるかを管理しますが、誰がダッシュボードで何をできるかは関知しません。VPN 内に複数のユーザーが存在する場合もありますし、デバイスの紛失リスクもあります。ネットワークアクセス ≠ アプリケーション認証です。
そのため、Claudie’s Session Dashboard は3段階の認証を実装しています:
- bcrypt ハッシュパスワード — 基本ログイン
- JWT トークン — セッション維持(WebSocket を含む)
- TOTP 2FA — オプションの2段階認証
さらに Tailscale CGNAT IP 検出による自動ログインを組み合わせれば、セキュリティは鉄壁でありながら、ユーザビリティも確保できます。
JWT + bcrypt — 基本認証フロー
パスワードの保存には bcrypt を使用します。OWASP 2025 ガイドラインによれば、cost factor 12 以上であれば依然として安全です。新規プロジェクトなら Argon2id がより良い選択ですが、bcrypt も現役です。
# server/auth.py — パスワードハッシュの生成と検証
import bcrypt
# 登録時:パスワード → bcrypt ハッシュ
hashed = bcrypt.hashpw(
password.encode(),
bcrypt.gensalt()
).decode()
# ログイン時:入力値と保存済みハッシュを比較
bcrypt.checkpw(
password.encode(),
user["password_hash"].encode()
)
ログイン成功後、JWT トークンを発行します。ペイロードには sub(ユーザー名)、role(admin/viewer)、exp(有効期限)が含まれます。このトークンが、以降のすべての REST API 呼び出しと WebSocket 接続の認証手段になります。
トークンの更新は、専用のリフレッシュエンドポイントではなく再ログインで処理します。ダッシュボードセッションのデフォルトは24時間で、Tailscale 自動ログイン環境では期限切れ後の再認証も摩擦がないため、この方式で十分です。

重要なポイントは WebSocket の認証です。HTTP エンドポイントはヘッダーに Bearer トークンを含めれば済みますが、WebSocket では最初のメッセージでトークンを送信する必要があります:
# server/terminal_routes.py — WebSocket 認証
@router.websocket("/ws/terminal")
async def terminal_ws(websocket: WebSocket):
await websocket.accept()
# 最初のメッセージ:JWT トークン認証
auth_msg = await websocket.receive_json()
token = auth_msg.get("token")
user = verify_jwt(token)
if not user:
await websocket.close(code=4001)
return
# 認証成功 → ターミナルプロトコル開始
await websocket.send_json(
{"type": "auth_ok"}
)
TOTP 2FA — pyotp 30秒ウィンドウの実装
パスワードだけでは不安な場合、TOTP 2FA を有効化できます。Google Authenticator や Authy などのアプリと互換性があります。
# server/auth.py — TOTP の設定と検証
import pyotp
# 2FA 有効化:シークレットキー生成
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
# QR コード用 URI 生成
uri = totp.provisioning_uri(
name=username,
issuer_name="Claudie Dashboard"
)
# ログイン時:6桁コード検証
# valid_window=1 → 前後30秒の誤差を許容
totp.verify(code, valid_window=1)
pyotp が RFC 6238 を実装しているため、設定はわずか数行で完了します。valid_window=1 により、デバイスの時計誤差 ±30秒を許容します。
Tailscale 自動ログイン — CGNAT 範囲検出のトリック
セキュリティは鉄壁ですが、自宅から毎回パスワード + TOTP を入力するのは面倒です。Tailscale VPN 内からの接続であれば、自動ログインが有効になるようにしました。
鍵となるのは、Tailscale が割り当てる IP 帯域です。RFC 6598 で定義された CGNAT(Carrier-Grade NAT)範囲 100.64.0.0/10 を使用します。通常のインターネットではこの帯域の IP は出現しないため、この IP からの接続 = Tailscale VPN 内にいることを意味します。
# server/auth.py — Tailscale CGNAT 検出
import ipaddress
_TAILSCALE_NETWORK = ipaddress.ip_network(
"100.64.0.0/10"
)
def is_tailscale_ip(self, ip_str: str) -> bool:
"""Tailscale VPN 内部からの接続かどうかを判定"""
return (
ipaddress.ip_address(ip_str)
in _TAILSCALE_NETWORK
)
Python 標準ライブラリの ipaddress モジュールだけで実装可能です。外部依存ゼロ。この関数が True を返せば、パスワード入力なしで自動的に JWT を発行します。
実際のフローはこうです — ブラウザが /api/auth/tailscale-login に GET リクエストを送り、サーバーがクライアント IP を抽出して is_tailscale_ip() で検査します。Tailscale 帯域であれば管理者権限の JWT を即座に返し、そうでなければ 401 を返します。フロントエンドはページロード時にこのエンドポイントをまず試行し、成功すればログイン画面なしでダッシュボードに直接遷移します。
Setup Wizard — 初回起動のゼロ設定自動化
認証システムがある以上、最初の管理者アカウントはどうやって作成するのでしょうか? ダッシュボードを初めて起動すると、Setup Wizard が自動的に表示されます。
ロジックはシンプルです — active_admin_count() == 0 であれば Setup Wizard モードに入ります。管理者アカウントを作成すると、デフォルトの admin アカウントが自動的に無効化され、以降は通常のログイン画面が表示されます。
設定ファイルも環境変数も不要で、SQLite DB にアクティブな管理者が存在するかどうかを確認するだけです。ゼロコンフィグの設計思想です。
Setup Wizard の後は、Settings ページでユーザー管理ができます。管理者は新しいユーザーの追加、ロール(admin/viewer)の変更、パスワードのリセットが可能です。users テーブルに is_active と is_default カラムを追加しており、デフォルトの admin アカウントは Setup 完了後に自動で is_default=True, is_active=False の状態になります。忘れられたアカウントがバックドアとして残ることはありません。

Claude Code ブラウザターミナル — xterm.js + PTY 接続
いよいよ今回のエピソードのハイライトです。ブラウザから Claude Code を直接操作する Claude Code ブラウザターミナルです。
まずアーキテクチャを確認します:
| レイヤー | コンポーネント | 役割 |
|---|---|---|
| フロントエンド | xterm.js | ブラウザターミナルエミュレーター |
| サーバー | terminal_proxy | WebSocket リレー + 認証 |
| エージェント | terminal_handler | PTY プロセス管理 |
| プロセス | claude CLI | 実際の Claude Code 実行 |
データフローはこうなります:xterm.js → WebSocket → terminal_proxy → WebSocket → terminal_handler → PTY → claude。双方向です。Claude の出力も同じ経路を逆順にたどり、ブラウザにレンダリングされます。

WebSocket プロトコルは3種類のメッセージタイプを使用します:
start— 新しいターミナルセッションの開始(または--resumeセッション ID を含む)input— キーボード入力の転送(base64 エンコード — CJK 対応の要)resize— ターミナルサイズの変更(cols, rows → PTY resize syscall)
もう一点 — xterm.js を React SPA で使う際によくある落とし穴があります。別ページに遷移するとコンポーネントが unmount され、ターミナルセッションが切断されるのです。Claudie Dashboard はこれを CSS display トグルで解決しています。CliPage を常にマウント状態で維持し、画面に表示しないときは display: none で非表示にするだけです。React のルーティング中も PTY プロセスは実行を継続するため、ダッシュボードから CLI ページに戻っても、実行中の Claude Code セッションがそのまま残っています。
pywinpty vs Unix pty — プラットフォーム分岐の実装
PTY(Pseudo-Terminal)は OS ごとに API が異なります。Claudie’s Session Dashboard は Windows と macOS/Linux の両方をサポートする必要があるため、プラットフォーム分岐は必須です。
Windows では pywinpty を使用します:
# agent/terminal_handler.py — Windows PTY
from winpty import PtyProcess
proc = PtyProcess.spawn(
cmd,
cwd=self._cwd,
env=self._spawn_env
)
# 出力読み取り:proc.read()
# 入力書き込み:proc.write(data)
Unix/macOS では標準ライブラリの pty モジュールを使用します:
# agent/terminal_handler.py — Unix PTY
import pty as pty_module
import asyncio, os
master_fd, slave_fd = pty_module.openpty()
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=self._cwd,
env=self._spawn_env,
preexec_fn=os.setsid
)
Windows PTY は同期 API(PtyProcess)、Unix は非同期 API(asyncio.create_subprocess_exec)です。この差異は terminal_handler.py 内部で抽象化されており、上位レイヤーはプラットフォームを意識する必要がありません。
韓国語入力の問題 — InvalidCharacterError と UTF-8 base64
Claude Code ブラウザターミナルは正常に動作していましたが…韓国語を入力するとエラーが発生します。InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.
原因は JavaScript の btoa() 関数です。この関数は Latin-1(ISO 8859-1)範囲(0x00〜0xFF)しか処理できません。韓国語、日本語、CJK 文字はこの範囲外であるため、btoa() が例外をスローします。
修正はわずか3行です:
// frontend/src/components/CliPage.tsx
// CJK セーフな base64 エンコーディング
const bytes = new TextEncoder().encode(data)
let binary = ""
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
sendInput(btoa(binary))
TextEncoder().encode() が文字列を UTF-8 バイト配列に変換し、各バイトを Latin-1 文字にマッピングしてから btoa() に渡します。バイト値は常に 0x00〜0xFF の範囲に収まるため、エラーは絶対に発生しません。
セキュリティ認証の3段階構築に数百行を費やしたのに、韓国語バグの修正はたった3行。開発とはそういうものです。
claude –resume — 30秒キャッシュによるセッション継続性
Claude Code ブラウザターミナルでセッションが切断されても、作業を継続できます。claude --resume <session-id> コマンドでセッションを復元します。
セッション一覧を取得するには、Claude Code の JSONL セッションファイルをスキャンする必要があります。ファイルが数十〜数百に及ぶ可能性があるため、パフォーマンスが重要です:
# agent/terminal_handler.py — セッションスキャン(要約)
import asyncio
from functools import partial
async def list_sessions(self):
# 30秒 TTL キャッシュを確認
if self._session_cache_valid():
return self._session_cache
loop = asyncio.get_event_loop()
# スレッドプールでファイル I/O を実行(ブロッキング防止)
sessions = await loop.run_in_executor(
self._executor,
partial(self._scan_jsonl_files)
)
self._update_cache(sessions)
return sessions # 最新順で上位20件
各 JSONL ファイルは全体を読み込みません。先頭 8KB(セッション ID、開始時刻)と末尾 2KB(最後のメッセージ)だけを読み取ります。JSONL ファイルが数十 MB であっても、探索時間はほぼゼロです。30秒 TTL キャッシュと組み合わせれば、UI からセッション一覧を連続リクエストしても、ファイルシステム I/O は30秒に1回だけです。エージェント起動時のプレウォーミングにより、初回リクエストも高速に応答します。
ユーザーの視点では、ダッシュボードに過去のセッション一覧が表示され、ワンクリックで claude --resume <session-id> が実行されます。数日前の作業コンテキストがそのまま復元されます。
次回エピソードの予告
認証と Claude Code ブラウザターミナルが完成しました。セキュリティは3段階で鉄壁、PTY ストリーミングでリアルタイム制御が可能です。このシステムが本番稼働している今、ソファからスマートフォンでダッシュボード URL を開けば — Tailscale VPN 接続確認、CGNAT 範囲検出、自動ログイン、ブラウザターミナル起動まで5秒以内に完了します。Windows ワークステーションで Claude Code がフル MCP アクセスで稼働しているのに、手元にあるのはスマートフォン1台だけです。
しかし、一つ足りないものがあります — 「このダッシュボードの運用コストはいくらなのか?」
Claude Code MAX サブスクリプションは月額 $200 の固定費です。しかし、xAI 画像生成、Gemini API、Qwen は別途課金です。すべてのコストを一画面でリアルタイムに確認しなければ、AI 運用の実際のコストは把握できません。
EP4 では MCP テレメトリとコスト追跡を実装します。7つの MCP サーバーを計測し、Claude トークン使用量を収集して「MAX なしで API だけだったらいくらかかるか」を算出し、Gemini・xAI・Qwen プロバイダー別の実際の支出を日次で集計します。Real Spend / If All API / MAX Savings — 3つの数値がダッシュボードにリアルタイムで表示されます。
SERIES — RDP なしで Claude Code をリモート制御する
| EP1 問題認識 — なぜ RDP を捨てたのか |
| EP2 アーキテクチャ — FastAPI + WebSocket + React |
| EP3 認証と CLI ストリーミング — セキュアリモートターミナル ◀ 現在のエピソード |
| EP4 コスト追跡と MCP テレメトリ — 運用コストの可視化 (coming soon) |
