SQLite で十分 — ローカルアプリの永続化・監査ログ・テスト戦略
連載「AI エージェントに SSH を渡すのが怖いので、人間承認ゲートウェイを自作した」第5回(永続化・テスト・運用編)。
- リポジトリ:
ssh-gete(ssh-gate-oss) — https://github.com/yoshiyakato/ssh-gate-oss- 対象バージョン:
main(タグ未発行。本記事のコード引用は記事公開時点のmainを参照)- この回は単体でも読めます。連載の入口は第1回(コンセプト編)です。
0. この記事のゴール
ssh-gete は「AI エージェントが要求した SSH コマンドを、人間が承認してから実行する」デスクトップアプリです。これまでの回で、MCP サーバー・承認フロー・GUI を作ってきました。第5回では、それらが扱う状態——接続先・MCP 設定・コマンド履歴(監査ログ)・エージェントごとの承認ポリシー——をどう保存しているかを扱います。
結論を先に言うと、永続化レイヤは SQLite 1ファイルです。サーバー不要、別プロセス不要、バックアップは「ファイルをコピーするだけ」。ローカルの単一ユーザー向けデスクトップアプリには、これで必要十分でした。
ただ「SQLite を使う」と言っても、実装には小さな判断がいくつも詰まっています。本記事ではそれを順に開きます。
-
ドライバ選び:cgo を避けて純 Go ドライバ
modernc.org/sqliteを選んだ理由 -
DSN の作り方:WAL・
busy_timeout・foreign_keys -
upsert で書き込みを統一する:すべての保存が
INSERT ... ON CONFLICT DO UPDATE - ID にタイムスタンプを埋め込む小技と、それが招いた履歴消失バグの修正
- 追加カラムを無痛で増やすマイグレーション戦略
- **「行が無ければ既定値」**という設定ロードの作法
- 接続先を消しても履歴は残す——監査ログの削除ポリシー
-
テスト戦略:
httptestとt.TempDir()で隔離された結合テスト、そして「安全契約」を守る回帰テスト -
環境変数リファレンス(
SSH_GETE_*)と既知の課題
参照コードは storage.go(341行)と mcp_server_test.go(338行)が中心です。
1. ドライバ選び — cgo を捨てて純 Go の modernc.org/sqlite
Go で SQLite を使うとき、最初の分岐がドライバです。定番は github.com/mattn/go-sqlite3 ですが、これは cgo を必要とします。cgo が要るということは:
- ビルドに C コンパイラ(macOS は clang、Windows は MinGW)が要る
-
CGO_ENABLED=1前提なので、クロスコンパイルが一気に面倒になる - CI で OS ごとにツールチェインを揃える必要が出る
このアプリは Windows / macOS の両対応で配布する予定(第7回)なので、cgo はできるだけ避けたい。そこで採用したのが純 Go 実装の modernc.org/sqlite です。go.mod を見ると依存はこうなっています。
// go.mod:10
require (
// ...
modernc.org/sqlite v1.52.0
)
storage.go の import も、ドライバ登録のためのブランクインポートが modernc.org/sqlite を指しています。
// storage.go:13
import (
"database/sql"
// ...
_ "modernc.org/sqlite"
)
純 Go なので CGO_ENABLED=0 のままビルドが通り、クロスコンパイルでつまずきません。database/sql のドライバ名は "sqlite"(cgo 版の "sqlite3" ではない)です。
注意点:第7回で詳しく扱いますが、cgo 版(
mattn/go-sqlite3)の Windows ビルドは MinGW の用意が要ります。本アプリは純 Go ドライバにしたことでこの罠を最初から踏みません。代償としてバイナリサイズはわずかに増え、極端な書き込み性能は cgo 版に劣りますが、ローカル単一ユーザーの用途では体感差はありません。
2. openStore と DSN — WAL・busy_timeout・foreign_keys
永続化レイヤの入口が openStore です。やっていることは「親ディレクトリを作る → DB を開く → マイグレーション」の3つだけ。
// storage.go:21
func openStore(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(on)")
if err != nil {
return nil, err
}
store := &Store{db: db, path: path}
if err := store.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
3点とも運用上の意味があります。
(1) ディレクトリを 0700 で作る
DB ファイルには接続先の資格情報も MCP の Bearer トークンも平文で入ります(後述)。だから親ディレクトリのパーミッションを 0700(所有者だけ読み書き実行可)に固めるのが最初の防御線です。「OS のファイル権限に守ってもらう」という割り切りで、ローカル単一ユーザー前提だから成立します。
(2) DSN で PRAGMA を渡す
modernc.org/sqlite は DSN のクエリ文字列で _pragma=... を渡せます。ここで2つ効かせています。
-
busy_timeout(5000):DB がロックされていたら、即エラーにせず最大5秒待つ。WAL でも書き込みは直列なので、GUI 操作と MCP からの書き込みが同時に来たときのdatabase is lockedを防ぎます。 -
foreign_keys(on):外部キー制約を有効化(SQLite は既定オフ)。現状の4テーブルは外部キーを張っていませんが、将来カラムを増やしたときの保険として最初から ON にしています。
(3) WAL モード
busy_timeout/foreign_keys は DSN で渡しますが、journal_mode=WAL だけは migrate() の中で実行します(接続単位ではなく DB ファイル単位の設定なので、明示的な PRAGMA 文として流すほうが分かりやすい)。
// storage.go:45
statements := []string{
`PRAGMA journal_mode=WAL`,
// CREATE TABLE ... が続く
}
WAL(Write-Ahead Logging)は「読み取りが書き込みをブロックしない」モードです。GUI が履歴を5秒ごとにポーリングで読む一方、MCP サーバーが新しいコマンド要求を書き込む——この読み書きの同時並行が、WAL なら素直に回ります。
3. upsert パターンの統一 — 全部 INSERT ... ON CONFLICT DO UPDATE
storage.go の CRUD は、保存系がすべて同じ形をしています。「無ければ INSERT、あれば UPDATE」を1文で済ませる upsert です。
接続先の保存を例に挙げます。
// storage.go:159
_, err = s.db.Exec(`
INSERT INTO connections (
name, type, host, port, user, auth_method, credential, serial_port, baud_rate,
connect_timeout, command_timeout, tags_json, description, status, last_checked, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
type = excluded.type,
host = excluded.host,
// ... 残りのカラムも excluded.* で上書き ...
updated_at = excluded.updated_at`,
conn.Name, conn.connectionType(), conn.Host, /* ... */ time.Now().Format(time.RFC3339),
)
ポイントは ON CONFLICT(name) DO UPDATE SET ... = excluded.xxx という書き方です。excluded は「いま INSERT しようとした行」を指す SQLite の特殊テーブルで、衝突時はそれをそのまま UPDATE 値に使えます。おかげで:
- アプリ側は「新規か更新か」を気にせず
saveConnection(conn)を呼ぶだけ - 主キー(
name)が同じなら自動で上書き、違えば新規挿入
この形を全テーブルで統一しています。
| テーブル | 主キー | 保存関数 |
|---|---|---|
connections |
name |
saveConnection(storage.go:154) |
mcp_settings |
id=1 固定 |
saveMCPSettings(storage.go:210) |
command_requests |
id |
saveCommandRequest(storage.go:259) |
agent_policies |
agent_name |
saveAgentPolicy(storage.go:313) |
mcp_settings は特殊で、テーブルに1行しか存在しません。スキーマ定義で id INTEGER PRIMARY KEY CHECK (id = 1) と制約しておき(storage.go:63)、保存は常に id=1 への upsert です(storage.go:217)。設定は「アプリ全体で1セット」なので、1行に固定するのが素直でした。
tags のように構造化された値は、
tags_json TEXTカラムにjson.Marshalした文字列で押し込んでいます(storage.go:155)。正規化して別テーブルに切る選択肢もありますが、tags は検索キーにしない「ただ表示するだけ」の値なので、JSON 一発で十分です。SQLite + JSON は個人開発の生産性が高い組み合わせです。
4. ID にタイムスタンプを埋め込む小技 — と、それが招いた履歴消失バグ
ここが本記事で一番おもしろい部分です。
4.1 まず「小技」から
コマンド要求の ID は REQ-<ミリ秒>-<連番> という形をしています。生成は app.go 側です。
// app.go:646
seq := requestIDSeq.Add(1)
return CommandRequest{
ID: fmt.Sprintf("REQ-%d-%d", now.UnixNano()/int64(time.Millisecond), seq),
// ...
}
この ID には作成時刻(ミリ秒)が埋め込まれています。だから保存時、別途タイムスタンプを持たなくても ID から復元できます。
// storage.go:259
func (s *Store) saveCommandRequest(req CommandRequest) error {
createdAt := time.Now().UnixMilli()
if parsed, err := parseRequestIDMillis(req.ID); err == nil {
createdAt = parsed
}
// INSERT ... created_at = createdAt
}
parseRequestIDMillis は ID の先頭ミリ秒だけを取り出します。
// storage.go:334
// parseRequestIDMillis extracts the millisecond timestamp from a request ID.
// IDs are "REQ-<millis>-<seq>"; legacy IDs are "REQ-<millis>" (no seq).
func parseRequestIDMillis(id string) (int64, error) {
rest := strings.TrimPrefix(id, "REQ-")
if idx := strings.IndexByte(rest, '-'); idx >= 0 {
rest = rest[:idx]
}
return strconv.ParseInt(rest, 10, 64)
}
- の最初の位置までを切り出すので、新形式 REQ-1717400000000-42 でも旧形式 REQ-1717400000000(連番なし)でも、同じコードでミリ秒が取れます。新旧両対応になっているわけです。この created_at(INTEGER)を使って履歴を ORDER BY created_at DESC で並べます(loadCommandRequests、storage.go:241)。並べ替え専用のカラムを別に持たずに済む、地味だが効く設計です。
4.2 そして招いたバグ — ミリ秒衝突で履歴が消えた
最初、ID は REQ-<ミリ秒> だけでした。連番(-<seq>)はありませんでした。これが落とし穴でした。
承認省略(bypass)を付与したエージェントが、コマンドをバースト的に連射するとどうなるか。同じミリ秒のうちに複数の要求が生まれ、ID が衝突します。ID は主キーなので、衝突した2件目以降の upsert は ON CONFLICT(id) DO UPDATE——つまり前の履歴を上書きして消してしまうのです。監査ログが取りこぼされるのは致命的でした。
修正は app.go 冒頭のコメントが端的に語っています。
// app.go:19
// requestIDSeq guarantees per-process uniqueness of command request IDs even
// when several requests are created within the same millisecond (e.g. an agent
// with approval-bypass firing commands in parallel). Without this the
// millisecond-resolution ID collides and the SQLite upsert overwrites earlier
// history rows.
var requestIDSeq atomic.Uint64
atomic.Uint64 の連番を ID 末尾に足すことで、同一ミリ秒でも ID が一意になります。parseRequestIDMillis が先頭ミリ秒だけ見る作りなので、並べ替え用のタイムスタンプ復元は壊れません。一意性は連番で、時刻はミリ秒で——役割を分けたわけです。
このバグは再発させたくないので、回帰テストで固定しました(後述の TestRapidCommandRequestsPersistDistinctly)。50件を高速生成して、ID が全部ユニークで、かつ再起動後も全件残っていることを検証します。
5. 追加カラムのマイグレーション — 「無ければ足す」で旧 DB を無痛移行
シリアルポート対応(第6回)で connections に type・serial_port・baud_rate の3カラムが後から増えました。問題は、すでに旧バージョンを使っているユーザーの DB です。旧 DB の connections テーブルにはこの3カラムがありません。
このアプリのマイグレーションは基本「CREATE TABLE IF NOT EXISTS を並べるだけ」の素朴な方式で、バージョン番号付きの本格的なマイグレーションフレームワークは使っていません。それでも追加カラムには対応する必要がある。そこで migrate() の最後に「無ければ足す」処理を入れています。
// storage.go:95
// Additive columns for the serial/SSH connection type. Older databases were
// created before these existed, so add them when missing.
for _, col := range []struct{ name, ddl string }{
{"type", `ALTER TABLE connections ADD COLUMN type TEXT NOT NULL DEFAULT 'SSH'`},
{"serial_port", `ALTER TABLE connections ADD COLUMN serial_port TEXT NOT NULL DEFAULT ''`},
{"baud_rate", `ALTER TABLE connections ADD COLUMN baud_rate INTEGER NOT NULL DEFAULT 0`},
} {
if err := s.addColumnIfMissing("connections", col.name, col.ddl); err != nil {
return err
}
}
addColumnIfMissing は SQLite の pragma_table_info で既存カラムを列挙し、目的のカラムが無いときだけ ALTER TABLE ADD COLUMN を実行します。
// storage.go:109
func (s *Store) addColumnIfMissing(table string, column string, ddl string) error {
rows, err := s.db.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return err
}
if name == column {
return rows.Err() // 既にある → 何もしない
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = s.db.Exec(ddl) // 無い → 追加
return err
}
設計のポイントは2つ。
- 冪等:何度起動しても、すでにあるカラムは触らない。旧 DB も新規 DB も同じコードパスで安全に通る。
-
既定値で旧データを救済:
typeは既定'SSH'、serial_portは''、baud_rateは0。だから種別なしで作られた旧レコードは、自動的に「SSH 接続先」として復活します。Connection.connectionType()(空・不正値をSSHに正規化する後方互換ヘルパー)と二段構えで、旧データが壊れません。
「バージョン管理されたマイグレーションへの第一歩」と言える程度の素朴さですが、個人開発のスキーマ追加にはこれで足ります。複雑なスキーマ変更(カラム削除・型変更・データ移行を伴うもの)が必要になったら、そのとき本格的なマイグレーション管理を入れればよい、という割り切りです。
6. 「行が無ければ既定値」— 設定ロードの作法
mcp_settings は1行しかないテーブルですが、初回起動時はその1行すら無い。このとき DB エラーで落とすのではなく、安全な既定値を返すのが loadMCPSettings です。
// storage.go:192
func (s *Store) loadMCPSettings(path string) (MCPSettings, string, bool, error) {
settings := defaultMCPSettings(path)
var payload string
var token string
err := s.db.QueryRow(`SELECT payload_json, token FROM mcp_settings WHERE id = 1`).Scan(&payload, &token)
if errors.Is(err, sql.ErrNoRows) {
return settings, "", false, nil // 行なし → 既定値 + loaded=false
}
if err != nil {
return settings, "", false, err
}
if err := json.Unmarshal([]byte(payload), &settings); err != nil {
return settings, "", false, err
}
settings.ConfigPath = path
return settings, token, true, nil
}
sql.ErrNoRows をエラーではなく「初期状態」として扱うのが要点です。戻り値の3つ目 bool(loaded)が false のとき「DB に設定が無い=既定値で始まる」と呼び出し側が判断できます。この loaded フラグは、旧バージョンの JSON 設定ファイルからの移行(migrateLegacyData、app.go:879)でも使われます——「DB にまだ無ければ、旧 JSON があれば取り込む」という判断に。
ちなみに
defaultMCPSettingsが返す既定値は、AutoExecuteLowRisk=false(低リスクでも自動実行しない)を含む安全側に倒した値です(第3回参照)。「設定が無いときに最も安全な状態で起動する」という、デフォルト安全の原則がここにも効いています。
保存側 saveMCPSettings には小さな配慮があります。永続化前に UI 専用フィールドを消すのです。
// storage.go:210
func (s *Store) saveMCPSettings(settings MCPSettings, token string) error {
settings.TokenInput = "" // フォーム入力中の生トークン
settings.TokenPreview = "" // マスク表示用の文字列
payload, err := json.Marshal(settings)
// ... id=1 への upsert ...
}
TokenInput / TokenPreview は UI のための一時的な値で、DB に残す意味がありません。永続化する直前にクリアして、payload_json を綺麗に保ちます。トークン本体は別カラム token に入ります。
7. 接続先を消しても履歴は残す — 監査ログの削除ポリシー
監査ログを名乗る以上、「消したつもりが証拠ごと消えた」では困ります。このアプリの削除は、対象ごとにポリシーが分かれています。
| 操作 | 対象テーブル | 関数 | 履歴への影響 |
|---|---|---|---|
| 接続先を削除 | connections |
deleteConnection(storage.go:187) |
コマンド履歴は残る |
| コマンド履歴を削除 | command_requests |
deleteCommandRequest(storage.go:288) |
その1行だけ消える |
| MCP 設定を削除 | mcp_settings |
deleteMCPSettings(storage.go:229) |
履歴とは無関係 |
鍵は command_requests が connections を外部キーで参照していないことです。履歴行は接続先名(host)をただの文字列として持っています。
// storage.go:187
func (s *Store) deleteConnection(name string) error {
_, err := s.db.Exec(`DELETE FROM connections WHERE name = ?`, name)
return err
}
この DELETE は connections テーブルにしか触れません。だから「もう使わない接続先を消す」と「その接続先に対して過去に何を実行したか」は独立です。接続先を消しても、誰がいつ何を実行したかの記録は監査ログとして残ります。外部キーで ON DELETE CASCADE を張ってしまうと履歴が連鎖削除されてしまうので、あえて張っていません(だからこそ foreign_keys(on) でも安全)。
永続化ポリシーをまとめると:
- 接続先の
credential(鍵パス・パスワード)は保存される - MCP の Bearer トークンも保存される(再起動後も認証を維持するため)
- SSH 鍵のパスフレーズは保存されない(メモリのみ。第3回参照)
- コマンド履歴は接続先を消しても残る(監査目的)
8. テスト戦略 — httptest と t.TempDir() で隔離する
ここからは mcp_server_test.go です。GUI(Wails)を起動せずに、バックエンドのロジックと永続化をGo の標準 testing だけで結合テストしています。
8.1 隔離の要:t.TempDir() で DB をテストごとに分ける
全テストの土台が newTestApp です。
// mcp_server_test.go:15
func newTestApp(t *testing.T) *App {
t.Helper()
t.Setenv("SSH_GETE_CONFIG_DIR", t.TempDir())
return NewApp()
}
たった2行ですが、効きます。
-
t.TempDir()がテストごとに一意の一時ディレクトリを作り、終了時に自動削除する -
t.Setenv("SSH_GETE_CONFIG_DIR", ...)でそのディレクトリを DB の保存先に指定する(環境変数は後述)。t.Setenvはテスト終了時に自動で元へ戻る
結果、各テストが完全に隔離された自分専用の SQLite を持つ。テスト同士が状態を共有しないので、並行実行しても順序に依存しても壊れません。「本物の DB を使うが、毎回まっさらに使い捨てる」——これが個人開発の永続化テストの現実解です。モックを書くより、本物の SQLite を一時ディレクトリで回すほうが、実装と乖離しない確実なテストになります。
8.2 httptest パターン — HTTP サーバーを起動せずにハンドラを叩く
MCP は HTTP(JSON-RPC over Streamable HTTP)で喋ります。これをテストする方法は2つあり、両方使い分けています。
(A) 本物のポートを開く——/healthz の疎通だけは実サーバーで確認します。
// mcp_server_test.go:21
listener, err := net.Listen("tcp", "127.0.0.1:0") // :0 で空きポートを OS に選ばせる
port := listener.Addr().(*net.TCPAddr).Port
_ = listener.Close() // 番号を取ったら一旦閉じる
// ... app.mcp.Port = port; app.startMCPServer() ...
resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/healthz")
127.0.0.1:0 で OS に空きポートを選ばせるのが定石です。固定ポートにすると「ポートが既に使われている」でテストが不安定になります。
(B) ハンドラを直接呼ぶ——ロジックの大半は httptest で、サーバーを起動せずにハンドラ関数を直接叩きます。こちらが主力です。
// mcp_server_test.go:52
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`)
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
rec := httptest.NewRecorder()
app.handleMCP(rec, req, app.mcp, app.mcpToken) // ← ハンドラを関数として呼ぶ
if rec.Code != http.StatusOK { /* ... */ }
httptest.NewRequest で偽のリクエストを、httptest.NewRecorder でレスポンス記録器を作り、ハンドラ handleMCP に渡すだけ。ネットワークを介さないので速く、デバッグもしやすい。rec.Body.Bytes() を rpcResponse に Unmarshal して中身を検証します。
8.3 「安全契約」を守る回帰テスト一覧
このアプリのテストは、機能の正常系より**「安全のための約束(契約)」を破っていないか**に重きを置いています。承認ゲートウェイは、壊れたら事故に直結するからです。
| テスト | 守っている契約 |
|---|---|
TestMCPHTTPServerHealth |
/healthz が 200 を返す(死活監視の前提) |
TestMCPToolsList |
tools/list が4つ以上のツールを返す |
TestMCPRequestCommandExecutionQueues |
sudo systemctl restart nginx は High 判定で「承認待ち」にキュー、エージェント名も記録 |
TestMCPExecuteCommandQueuesLowRisk |
whoami(Low)でも自動実行せず「承認待ち」でキュー ← デフォルト安全の核心 |
TestMCPCommandRequiresAgentName |
agent_name 無しは -32602 エラー、キューに積まれない(匿名実行の禁止) |
TestMCPRejectsSerialConnection |
シリアル接続先への MCP 実行は拒否、キューにも積まれない(役割分担の強制) |
TestSQLitePersistsCommandRequests |
要求が再起動後も SQLite から復元される。DB パスが ssh-gete.sqlite であることも確認 |
TestRapidCommandRequestsPersistDistinctly |
高速連続生成した要求が一意 ID を保ち、全件が再起動後も残る(§4 の履歴消失バグの回帰防止) |
TestDeleteConnectionPersists |
接続先削除が永続化され、再起動でも再シードで復活しない |
TestDeleteCommandRequestPersists |
コマンド履歴削除が永続化される |
TestAgentApprovalBypassPersists |
承認省略ポリシーが永続化される |
太字の3本が特に重要です。
TestMCPExecuteCommandQueuesLowRisk は、「低リスクでも勝手に実行しない」という本アプリの根本契約を守ります。whoami のような無害なコマンドでさえ、自動実行されず承認待ちに入ることを固定しています。
// mcp_server_test.go:146
if app.requests[0].Command != "whoami" || app.requests[0].Risk != "Low" || app.requests[0].Status != "承認待ち" {
t.Fatalf("unexpected request: %+v", app.requests[0])
}
TestRapidCommandRequestsPersistDistinctly は §4 のバグの番人です。50件を一気に積み、ID 重複が無いこと・再起動後に全件残ることを確認します。
// mcp_server_test.go:248
const n = 50
ids := map[string]bool{}
for i := 0; i < n; i++ {
req := app.queueCommand("ローカル確認用", "whoami", "burst", "codex-local", "Low")
if ids[req.ID] {
t.Fatalf("duplicate request ID generated: %s", req.ID)
}
ids[req.ID] = true
}
app.shutdown(t.Context())
reloaded := NewApp() // 同じ SSH_GETE_CONFIG_DIR で開き直す=再起動の再現
if len(reloaded.requests) != n {
t.Fatalf("persisted requests=%d, want %d", len(reloaded.requests), n)
}
TestMCPRejectsSerialConnection は、シリアル接続先(対話 GUI 専用)が MCP の実行経路から拒否されること、しかも拒否時にキューを汚さないことを守ります(第6回参照)。
// mcp_server_test.go:214
if resp.Error == nil || !strings.Contains(resp.Error.Message, "serial") {
t.Fatalf("expected serial rejection, got: %+v", resp)
}
if len(app.GetInitialData().Requests) != 0 {
t.Fatalf("serial command should not be queued")
}
永続化テスト(TestSQLitePersistsCommandRequests など)の共通パターンは「shutdown → NewApp() で開き直す」です。同じ SSH_GETE_CONFIG_DIR を指したまま App を作り直すことで、プロセス再起動を1つのテスト関数の中で再現しています。これにより「メモリ上だけで成立しているように見えて、実は DB に書けていなかった」というバグを確実に捕まえられます。
9. 設定・環境変数リファレンス(SSH_GETE_*)
運用で効く環境変数を一覧にします。GUI でも設定できる項目が多いですが、環境変数は GUI を開かずに(CI・コンテナ・スクリプト経由で)固定したいときに有効です。
| 環境変数 | 効果 | 関連箇所 |
|---|---|---|
SSH_GETE_CONFIG_DIR |
設定/DB の保存先ディレクトリを上書き。テストの隔離にも使う |
appConfigDir(app.go:833) |
SSH_GETE_MCP_ADDR |
MCP 待受の host:port を上書き |
applyMCPEnv(app.go:1067) |
SSH_GETE_TOKEN |
Bearer トークンを設定し、Bearer 認証を有効化 |
applyMCPEnv(app.go:1076) |
SSH_GETE_STRICT_HOSTKEY |
1 で known_hosts の厳格検証を強制 |
applyMCPEnv(app.go:1080)/ ssh_exec.go:182
|
SSH_AUTH_SOCK |
ssh-agent の利用(標準的な SSH 環境変数) |
ssh_exec.go の agent 認証 |
USER / USERNAME
|
シードする「ローカル確認用」接続のユーザー名(Unix は USER、Windows は USERNAME) |
currentUsername(app.go:847) |
DB のパスは SSH_GETE_CONFIG_DIR 配下の ssh-gete.sqlite に決まります。
// app.go:833
func appConfigDir() string {
if dir := strings.TrimSpace(os.Getenv("SSH_GETE_CONFIG_DIR")); dir != "" {
return dir
}
dir, err := os.UserConfigDir() // 未指定なら OS 標準の設定ディレクトリ
if err != nil || dir == "" {
dir = "."
}
return filepath.Join(dir, appName)
}
// app.go:859
func defaultDatabasePath() string {
return filepath.Join(appConfigDir(), "ssh-gete.sqlite")
}
環境変数の適用は applyMCPEnv(app.go:1066)に集約されています。例えば SSH_GETE_TOKEN を渡すと、トークンを設定したうえで BearerEnabled = true まで自動で立てます。「トークンを渡したのに認証が無効のまま」という事故を防ぐ作りです。
// app.go:1076
if envToken := os.Getenv("SSH_GETE_TOKEN"); envToken != "" {
*token = envToken
settings.BearerEnabled = true
}
主要な既定値(環境変数で上書きしない場合):待受 127.0.0.1:8787、エンドポイント /mcp・/healthz・/audit/events、最大ボディ 256KB、最大出力 128KB、接続タイムアウト 10秒、コマンドタイムアウト 30秒。
10. 既知の課題と改善余地
正直コーナーです。割り切った点を隠さず書きます。
10.1 マイグレーションのバージョン管理がない
§5 の通り、スキーマ進化は「CREATE TABLE IF NOT EXISTS + addColumnIfMissing」の素朴な方式で、バージョン番号を持っていません。カラム追加には対応できますが、カラム削除・型変更・テーブル間のデータ移行のような「複雑なスキーマ変更」には向きません。SQLite は ALTER TABLE の機能が限定的(カラム削除は新しめのバージョンが必要、など)なので、将来そういう変更が必要になったら、PRAGMA user_version を使ったバージョン付きマイグレーションへ移行するのが筋でしょう。現状は「追加だけで足りている」から素朴なままにしている、という判断です。
10.2 トークンが平文で DB に保存される
MCP の Bearer トークンは mcp_settings.token カラムに平文で入ります(接続先の credential も同様)。これは「ローカル単一ユーザー前提」という割り切りで、保護は 0700 ディレクトリのファイル権限に依存しています。OS のキーチェーン(macOS Keychain / Windows Credential Manager)に逃がす選択肢はありますが、クロスプラットフォーム実装のコストが増えるため、現状は採用していません。「DB ファイルそのものを守れる前提なら、平文保存で十分」という線引きです。複数ユーザーが同じマシンを共有する、あるいは DB を同期サービスに置くような運用には向きません。
10.3 粗粒度ロックと未使用フィールド
状態はすべて単一の RWMutex(App.mu)で保護していますが、長時間の SSH 実行は排他区間に含めない設計です(第3回)。規模的に問題はありません。また executeCommandArgs には force_queue / timeout_seconds など将来用の未使用フィールドが残っています(第2回)。
まとめ — ローカル単一ユーザーなら SQLite + upsert + ディレクトリ権限で必要十分
-
ドライバは純 Go の
modernc.org/sqlite。cgo を避けたことでクロスコンパイルの罠(C コンパイラ・MinGW)を最初から踏まない。 -
DSN で
busy_timeoutとforeign_keysを、PRAGMAで WAL を。読み書き同時並行(GUI ポーリング × MCP 書き込み)が素直に回る。 -
保存はすべて
INSERT ... ON CONFLICT DO UPDATEの upsert に統一。アプリ側は「新規か更新か」を気にしない。設定はid=1固定の1行テーブル。 -
ID にミリ秒を埋めてタイムスタンプを兼ねる小技。ただしミリ秒衝突で履歴が消えたので、連番(
-<seq>)で一意化し、回帰テストで固定した。 -
追加カラムは
addColumnIfMissingで無痛移行。既定値で旧データを救済し、冪等に走る。 - **「行が無ければ安全な既定値」**で初回起動を成立させる。
sql.ErrNoRowsはエラーではなく初期状態。 - 接続先を消しても履歴は残す。外部キーを張らないことで監査ログを守る。
-
テストは
t.TempDir()で隔離した本物の SQLite とhttptest。shutdown → NewApp()で再起動を再現し、「安全契約」を回帰テストで縛る。
データベース選定で悩んだら、まず用途を見極めるのが先です。ローカルの単一ユーザー向けデスクトップアプリなら、PostgreSQL も Redis も要りません。SQLite 1ファイル + upsert + 0700 ディレクトリで、永続化・監査ログ・テストまで含めて必要十分でした。「足りなくなってから足す」が個人開発の正解です。
次回(第6回)は、この永続化の上にシリアルポート対応を載せ、SSH と同じ画面で組み込み機器のコンソールを開く——トランスポート抽象化と xterm.js の話をします。
参照コード(本記事の引用元)
-
go.mod(modernc.org/sqlite v1.52.0)— 純 Go の SQLite ドライバ -
storage.go(341行)—openStore/DSN、migrate/addColumnIfMissing、全 upsert、loadMCPSettings(行なし=既定値)、parseRequestIDMillis -
app.go—requestIDSeq(ID 一意化、app.go:19)、newCommandRequest(app.go:644)、appConfigDir/defaultDatabasePath(app.go:833/859)、applyMCPEnv(app.go:1066) -
mcp_server_test.go(338行)—newTestApp(t.TempDir隔離)、httptestパターン、安全契約の回帰テスト群
逐行の詳細は
docs/解説.md第9章「永続化レイヤstorage.go」・付録D・第11章「テスト」・第15章「環境変数」・第16章「既知の注意点」を参照。
この記事はオープンソース ssh-gate の紹介記事です。
クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。
