Conduit — Web SSH Terminal

⚠️ 開発中 (Work in Progress)
このプロジェクトは現在開発中であり、実環境での動作確認は行っていません。
本番環境での使用は推奨しません。
ブラウザから SSH に接続できる Web ターミナルアプリケーションです。
HashiCorp Vault が発行する短命 SSH 証明書(TTL=5分)で認証し、WebSocket 経由でリアルタイムにターミナルを操作できます。
アーキテクチャ
Browser (xterm.js)
│ WebSocket (binary frames)
▼
Go HTTP Server
├─ POST /api/connect → Vault で証明書発行 → SSH 接続確立 → セッション生成
├─ GET /ws?token=… → WebSocket ↔ SSH ストリームブリッジ
├─ GET /ws?share=… → 読み取り専用ビューア接続
├─ GET /api/sessions → アクティブセッション一覧(管理用)
├─ POST /api/sessions/{t}/share → 共有トークン発行
├─ GET /api/logs → 接続ログ一覧
└─ GET /api/recordings/{id} → asciinema 録画ファイル配信
│ │
▼ ▼
Target SSH Server (証明書認証) 接続ログ / 録画ストア
(任意で ProxyJump 踏み台経由) (SQLite / メモリ・.cast ファイル)
主要な設計ポイント
| 機能 |
詳細 |
| 短命 SSH 証明書 |
Vault SSH Secrets Engine で TTL=5分の証明書を発行。秘密鍵はメモリ上のみに保持しディスクに書かない |
| グレース期間再接続 |
WebSocket 切断後 15 分間は SSH セッションを保持。同じトークンで再接続すると続きから操作できる |
| バックプレッシャー |
SSH → クライアント方向のチャンネルが詰まった場合、50ms 待って送れなければドロップ。ゴルーチンのフリーズを防ぐ |
| ProxyJump(踏み台) |
接続先の手前に踏み台ホストを指定して多段 SSH 接続を確立。踏み台側も Vault / Password / Public Key 認証に対応 |
| セッション録画 |
RECORDING_ENABLED 有効時、ターミナル出力を asciinema v2 (.cast) 形式で記録。接続ログ画面から再生できる |
| 接続ログの永続化 |
接続履歴を SQLite に保存(DB_PATH 未設定時はメモリ)。失敗した接続もエラー付きで記録 |
| 共有セッション(閲覧専用) |
共有トークンを発行すると、第三者が読み取り専用ビューアとして同じセッションをリアルタイム閲覧できる |
技術スタック
バックエンド
- Go 1.25
golang.org/x/crypto/ssh — SSH クライアント・証明書認証
github.com/gorilla/websocket — WebSocket サーバー
modernc.org/sqlite — 接続ログ永続化(cgo 不要の Pure Go SQLite)
- HashiCorp Vault HTTP API — SSH 証明書署名
- asciinema v2 (
.cast) — セッション録画フォーマット
フロントエンド
- React 18 + TypeScript
@xterm/xterm — ターミナルエミュレータ(WebGL レンダラー)
@xterm/addon-fit — ウィンドウサイズ自動追従
@xterm/addon-webgl — GPU アクセラレーション描画
@xterm/addon-search — ターミナル内検索
asciinema-player — 録画再生プレイヤー
- Vite 5 — ビルドツール・開発サーバー
ディレクトリ構成
.
├── cmd/server/ # エントリポイント (main.go)
├── internal/
│ ├── api/ # HTTP ハンドラー (connect, terminal, sessions, share, logs, recordings)
│ ├── config/ # 環境変数設定・シークレット型
│ ├── connlog/ # 接続ログストア (SQLite / メモリ)
│ ├── recording/ # asciinema v2 録画レコーダー
│ ├── session/ # セッション状態管理・GC・共有トークン
│ ├── sshconn/ # 鍵生成・SSH ダイアル・ProxyJump・証明書サイナー
│ ├── tunnel/ # WebSocket↔SSH ポンプ・PTY リサイズ・バックプレッシャー
│ └── vault/ # Vault クライアント
├── pkg/token/ # セッショントークン生成
├── tests/ # E2E 統合テスト
└── frontend/ # React フロントエンド
└── src/
├── api/ # REST クライアント (connect, sessions, fetch)
├── components/ # ConnectForm, Terminal, TabBar, SessionList, LogPage, NewConnectionOverlay
├── hooks/ # useTerminal, useWebSocket, useProfiles, useConnectionHistory
├── themes/ # ターミナルカラーテーマ
├── utils/ # crypto, storage, parseSshConfig など
└── types/ # 型定義
本番デプロイ
詳細は DEPLOY.md を参照してください。
接続先 SSH サーバーのセットアップ
Conduit から接続したい SSH サーバーで以下のスクリプトを実行します:
curl -fsSL https://raw.githubusercontent.com/nagayon-935/Conduit/main/scripts/setup-ssh-server.sh \
| bash -s http://<VaultのIP>:8200
またはリポジトリをクローンしている場合:
bash scripts/setup-ssh-server.sh http://<VaultのIP>:8200
スクリプトが行うこと:
- Vault から CA 公開鍵を取得し
/etc/ssh/trusted-ca.pub に保存
/etc/ssh/sshd_config に TrustedUserCAKeys を追記
sshd を再読み込み
ユーザーガイド
SSH 接続
ブラウザで Conduit を開くと接続フォームが表示されます。
Host / Port / User を入力し、認証方式を選択して Connect を押します。
認証方式
| 方式 |
対象 |
必要な入力 |
| Vault(デフォルト) |
Vault CA を信頼するよう設定済みのサーバー |
なし(証明書は自動発行) |
| Password |
パスワード認証を許可する任意の SSH サーバー・NW機器 |
パスワード |
| Public Key |
公開鍵認証を許可する任意の SSH サーバー |
秘密鍵(PEM 貼り付けまたはファイル選択) |
パスワードはブラウザに保存されません。秘密鍵は、その場限りの接続では保存されませんが、プロファイルとして保存した場合のみ暗号化して localStorage に保持されます。
複数ホストへの同時接続
+ Add host ボタンで接続先を追加すると、Connect All で全ホストへ並列接続してスプリット表示できます。
踏み台(ProxyJump)経由の接続
接続フォームの Jump Host に踏み台ホストを入力すると、その踏み台を経由して目的のサーバーへ多段 SSH 接続します。
踏み台側の認証方式(Vault / Password / Public Key)も個別に指定できます。Jump Host を空にすると踏み台は使用されません。
プロファイル
よく使う接続先をプロファイルとして保存できます。
- 保存: フォーム入力後、+ Save as Profile からプロファイル名を入力して保存
- 読み込み: Profiles リストのプロファイルをクリックすると Host・Port・User・認証方式・踏み台設定が自動入力
- Import: Import ~/.ssh/config ボタンで
~/.ssh/config ファイルを選択すると一括インポート
- 記憶: 一度接続した認証方式はプロファイル・履歴に記録され、次回選択時に自動で切り替わる
タブ・レイアウト
接続中は画面上部のタブバーで複数セッションを管理できます。
| 操作 |
方法 |
| 新規接続 |
+ ボタン |
| タブ切り替え |
タブをクリック |
| タブ並び替え |
タブをドラッグ&ドロップ |
| タブを閉じる |
タブ内の ✕ ボタン |
| 左右分割 |
レイアウトボタン(⊞)から選択 |
| 上下分割 |
レイアウトボタン(⊞)から選択 |
| 2×2 グリッド |
レイアウトボタン(⊞)から選択 |
| 分割サイズ変更 |
ペイン間の仕切りをドラッグ |
プロファイルと一致するタブはプロファイル名で表示されます。
セッションの再接続
WebSocket が切断されても 15 分間はサーバー側で SSH セッションが保持されます。
ブラウザをリロードするか再度アクセスすると自動で再接続されます。
セッションの共有(閲覧専用)
ターミナル右上の Share ボタンを押すと読み取り専用の共有 URL(?share=<token>)が発行され、クリップボードにコピーされます。
この URL を開いた相手は同じセッションをリアルタイムに閲覧できますが、入力はできません。
Stop sharing で共有を失効させると、ビューアの接続は切断されます。
接続ログ・録画
接続フォームのナビゲーションメニューから Logs を開くと、過去の接続履歴(接続/切断時刻、失敗時のエラー)を確認できます。
DB_PATH を設定している場合、ログは SQLite に永続化され再起動後も残ります。
RECORDING_ENABLED を有効にして接続したセッションは asciinema 形式で録画され、ログ画面の各エントリから再生できます。
同じメニューの Sessions からは現在アクティブなセッション一覧を表示し、任意のセッションを強制終了できます。
カラーテーマ
ターミナル右上のテーマセレクタから配色を切り替えられます(Tokyo Night / Dracula / Solarized Dark / One Dark)。
選択したテーマは localStorage に保存され、次回起動時に引き継がれます。
ターミナル操作
キーボードショートカット
| ショートカット |
機能 |
Ctrl + = |
フォントサイズを拡大 |
Ctrl + - |
フォントサイズを縮小 |
Ctrl + F |
ターミナル内検索を開く / 閉じる |
Enter |
次の検索結果へ |
Shift + Enter |
前の検索結果へ |
Escape |
検索を閉じる |
フォントサイズは変更後も localStorage に保持され、次回起動時に引き継がれます。
ローカル開発セットアップ
前提条件
- Go 1.25+
- Node.js 18+
- HashiCorp Vault(SSH Secrets Engine 有効化済み)
環境変数
| 変数名 |
必須 |
デフォルト |
説明 |
VAULT_ADDR |
✅ |
— |
Vault サーバーのアドレス (例: http://127.0.0.1:8200) |
VAULT_TOKEN |
✅ |
— |
Vault アクセストークン |
VAULT_SSH_ROLE |
✅ |
— |
SSH 署名に使用するロール名 |
VAULT_SSH_MOUNT |
|
ssh |
Vault SSH Secrets Engine のマウントパス |
SERVER_PORT |
|
8080 |
HTTP サーバーのリッスンポート |
GRACE_PERIOD |
|
15m |
WebSocket 切断後にセッションを保持する期間 |
SESSION_GC_INTERVAL |
|
1m |
期限切れセッションの GC 実行間隔 |
CORS_ALLOWED_ORIGINS |
|
http://localhost:5173 |
API アクセスを許可する CORS オリジン(カンマ区切り) |
KNOWN_HOSTS_PATH |
|
— |
ホスト鍵検証に使う known_hosts ファイルのパス。未設定時はホスト鍵検証を行わない |
DB_PATH |
|
— |
接続ログを永続化する SQLite ファイルのパス。未設定時はメモリストアを使用 |
RECORDING_ENABLED |
|
— |
値が設定されているとセッション録画を有効化 |
RECORDING_DIR |
|
./recordings |
.cast 録画ファイルの保存先ディレクトリ |
バックエンド起動
# 依存パッケージ取得
go mod download
# ビルド & 起動
make build
make run
# または開発モード(go run)
make dev
フロントエンド起動
cd frontend
npm install
npm run dev # http://localhost:5173 で起動
バックエンドは localhost:8080 で起動している必要があります。
Vite の開発サーバーが /api と /ws を自動プロキシします。
テスト
# 全テスト(レースディテクター付き)
make test
# カバレッジレポート
go test -covermode=atomic -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
カバレッジ(現状)
| パッケージ |
カバレッジ |
internal/vault |
89.5% |
internal/recording |
81.0% |
internal/config |
80.6% |
pkg/token |
75.0% |
internal/tunnel |
69.7% |
internal/session |
62.9% |
internal/connlog |
61.5% |
internal/api |
53.0% |
internal/sshconn |
51.6% |
ライブ WebSocket 接続を必要とする経路(readPump / writePump など)は静的テストでは未カバーです。
API
POST /api/connect
SSH 接続を確立してセッションを作成します。
リクエスト
// Vault 証明書認証(デフォルト)
{ "host": "192.168.1.10", "port": 22, "user": "ubuntu", "auth_type": "vault" }
// パスワード認証
{ "host": "192.168.1.10", "port": 22, "user": "admin", "auth_type": "password", "password": "..." }
// 公開鍵認証
{ "host": "192.168.1.10", "port": 22, "user": "ubuntu", "auth_type": "pubkey", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n..." }
// ProxyJump(踏み台経由)— jump_* フィールドを追加(任意)
{
"host": "10.0.0.5", "port": 22, "user": "ubuntu", "auth_type": "vault",
"jump_host": "bastion.example.com", "jump_port": 22, "jump_user": "ubuntu", "jump_auth_type": "vault"
}
レスポンス (201)
{
"session_token": "a3f9...",
"expires_at": "2024-01-01T00:15:00Z",
"message": "SSH session established to 192.168.1.10:22"
}
GET /ws?token=<session_token>
WebSocket にアップグレードして双方向ターミナルストリームを開きます。
-
Binary frame — ターミナルの入出力データ
-
Text frame — 制御メッセージ (JSON)
{ "type": "ping" }
{ "type": "resize", "cols": 120, "rows": 40 }
GET /ws?share=<share_token>
共有トークンで読み取り専用ビューアとして接続します。入力は無視され、出力のみがストリームされます。
GET /api/sessions
アクティブなセッションの一覧を返します(管理 UI 用)。
DELETE /api/sessions/{token}
指定したセッションを強制終了します(204 No Content)。
POST /api/sessions/{token}/share
セッションに対する読み取り専用の共有トークンを発行します。
// レスポンス (201)
{ "share_token": "…", "url": "http://<host>/?share=…", "expires_at": "2024-01-01T00:15:00Z" }
DELETE /api/sessions/{token}/share/{shareToken}
共有トークンを失効させます(204 No Content)。
GET /api/logs
接続ログ(接続/切断時刻、エラー、録画パスの有無)を新しい順に返します。
GET /api/recordings/{id}
接続ログ ID に対応する asciinema 録画(application/x-asciicast)を配信します。
GET /healthz
ライブネスプローブ({"status":"ok"})。
ライセンス
MIT