1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【#5】承認・履歴付 OSS SSH MCP server ssh-gate -- 永続化・テスト・運用編

1
Last updated at Posted at 2026-07-22

SQLite で十分 — ローカルアプリの永続化・監査ログ・テスト戦略

連載「AI エージェントに SSH を渡すのが怖いので、人間承認ゲートウェイを自作した」第5回(永続化・テスト・運用編)。

  • リポジトリ: ssh-getessh-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_timeoutforeign_keys
  • upsert で書き込みを統一する:すべての保存が INSERT ... ON CONFLICT DO UPDATE
  • ID にタイムスタンプを埋め込む小技と、それが招いた履歴消失バグの修正
  • 追加カラムを無痛で増やすマイグレーション戦略
  • **「行が無ければ既定値」**という設定ロードの作法
  • 接続先を消しても履歴は残す——監査ログの削除ポリシー
  • テスト戦略httptestt.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 saveConnectionstorage.go:154
mcp_settings id=1 固定 saveMCPSettingsstorage.go:210
command_requests id saveCommandRequeststorage.go:259
agent_policies agent_name saveAgentPolicystorage.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 で並べます(loadCommandRequestsstorage.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回)で connectionstypeserial_portbaud_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_rate0。だから種別なしで作られた旧レコードは、自動的に「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 設定ファイルからの移行(migrateLegacyDataapp.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 deleteConnectionstorage.go:187 コマンド履歴は残る
コマンド履歴を削除 command_requests deleteCommandRequeststorage.go:288 その1行だけ消える
MCP 設定を削除 mcp_settings deleteMCPSettingsstorage.go:229 履歴とは無関係

鍵は command_requestsconnections を外部キーで参照していないことです。履歴行は接続先名(host)をただの文字列として持っています。

// storage.go:187
func (s *Store) deleteConnection(name string) error {
    _, err := s.db.Exec(`DELETE FROM connections WHERE name = ?`, name)
    return err
}

この DELETEconnections テーブルにしか触れません。だから「もう使わない接続先を消す」と「その接続先に対して過去に何を実行したか」は独立です。接続先を消しても、誰がいつ何を実行したかの記録は監査ログとして残ります。外部キーで ON DELETE CASCADE を張ってしまうと履歴が連鎖削除されてしまうので、あえて張っていません(だからこそ foreign_keys(on) でも安全)。

永続化ポリシーをまとめると:

  • 接続先の credential(鍵パス・パスワード)は保存される
  • MCP の Bearer トークンも保存される(再起動後も認証を維持するため)
  • SSH 鍵のパスフレーズは保存されない(メモリのみ。第3回参照)
  • コマンド履歴は接続先を消しても残る(監査目的)

8. テスト戦略 — httptestt.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 nginxHigh 判定で「承認待ち」にキュー、エージェント名も記録
TestMCPExecuteCommandQueuesLowRisk whoamiLowでも自動実行せず「承認待ち」でキュー ← デフォルト安全の核心
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 など)の共通パターンは「shutdownNewApp() で開き直す」です。同じ SSH_GETE_CONFIG_DIR を指したまま App を作り直すことで、プロセス再起動を1つのテスト関数の中で再現しています。これにより「メモリ上だけで成立しているように見えて、実は DB に書けていなかった」というバグを確実に捕まえられます。


9. 設定・環境変数リファレンス(SSH_GETE_*

運用で効く環境変数を一覧にします。GUI でも設定できる項目が多いですが、環境変数は GUI を開かずに(CI・コンテナ・スクリプト経由で)固定したいときに有効です。

環境変数 効果 関連箇所
SSH_GETE_CONFIG_DIR 設定/DB の保存先ディレクトリを上書き。テストの隔離にも使う appConfigDirapp.go:833
SSH_GETE_MCP_ADDR MCP 待受の host:port を上書き applyMCPEnvapp.go:1067
SSH_GETE_TOKEN Bearer トークンを設定し、Bearer 認証を有効化 applyMCPEnvapp.go:1076
SSH_GETE_STRICT_HOSTKEY 1 で known_hosts の厳格検証を強制 applyMCPEnvapp.go:1080)/ ssh_exec.go:182
SSH_AUTH_SOCK ssh-agent の利用(標準的な SSH 環境変数) ssh_exec.go の agent 認証
USER / USERNAME シードする「ローカル確認用」接続のユーザー名(Unix は USER、Windows は USERNAME currentUsernameapp.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")
}

環境変数の適用は applyMCPEnvapp.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 EXISTSaddColumnIfMissing」の素朴な方式で、バージョン番号を持っていません。カラム追加には対応できますが、カラム削除・型変更・テーブル間のデータ移行のような「複雑なスキーマ変更」には向きません。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 粗粒度ロックと未使用フィールド

状態はすべて単一の RWMutexApp.mu)で保護していますが、長時間の SSH 実行は排他区間に含めない設計です(第3回)。規模的に問題はありません。また executeCommandArgs には force_queue / timeout_seconds など将来用の未使用フィールドが残っています(第2回)。


まとめ — ローカル単一ユーザーなら SQLite + upsert + ディレクトリ権限で必要十分

  • ドライバは純 Go の modernc.org/sqlite。cgo を避けたことでクロスコンパイルの罠(C コンパイラ・MinGW)を最初から踏まない。
  • DSN で busy_timeoutforeign_keys を、PRAGMA で WAL を。読み書き同時並行(GUI ポーリング × MCP 書き込み)が素直に回る。
  • 保存はすべて INSERT ... ON CONFLICT DO UPDATE の upsert に統一。アプリ側は「新規か更新か」を気にしない。設定は id=1 固定の1行テーブル。
  • ID にミリ秒を埋めてタイムスタンプを兼ねる小技。ただしミリ秒衝突で履歴が消えたので、連番(-<seq>)で一意化し、回帰テストで固定した。
  • 追加カラムは addColumnIfMissing で無痛移行。既定値で旧データを救済し、冪等に走る。
  • **「行が無ければ安全な既定値」**で初回起動を成立させる。sql.ErrNoRows はエラーではなく初期状態。
  • 接続先を消しても履歴は残す。外部キーを張らないことで監査ログを守る。
  • テストは t.TempDir() で隔離した本物の SQLite と httptestshutdown → NewApp() で再起動を再現し、「安全契約」を回帰テストで縛る。

データベース選定で悩んだら、まず用途を見極めるのが先です。ローカルの単一ユーザー向けデスクトップアプリなら、PostgreSQL も Redis も要りません。SQLite 1ファイル + upsert + 0700 ディレクトリで、永続化・監査ログ・テストまで含めて必要十分でした。「足りなくなってから足す」が個人開発の正解です。

次回(第6回)は、この永続化の上にシリアルポート対応を載せ、SSH と同じ画面で組み込み機器のコンソールを開く——トランスポート抽象化と xterm.js の話をします。


参照コード(本記事の引用元)

  • go.modmodernc.org/sqlite v1.52.0)— 純 Go の SQLite ドライバ
  • storage.go(341行)— openStore/DSN、migrate/addColumnIfMissing、全 upsert、loadMCPSettings(行なし=既定値)、parseRequestIDMillis
  • app.gorequestIDSeq(ID 一意化、app.go:19)、newCommandRequestapp.go:644)、appConfigDir/defaultDatabasePathapp.go:833/859)、applyMCPEnvapp.go:1066
  • mcp_server_test.go(338行)— newTestAppt.TempDir隔離)、httptest パターン、安全契約の回帰テスト群

逐行の詳細は docs/解説.md 第9章「永続化レイヤ storage.go」・付録D・第11章「テスト」・第15章「環境変数」・第16章「既知の注意点」を参照。

この記事はオープンソース ssh-gate の紹介記事です。

図1.png

クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?