ある日、多サイト管理者のユーザーから不可解な不具合報告が届きました。「アプリで接続テストを 2-3 回繰り返したら、その後 その IP からの SSH が長時間繋がらなくなる 」。エラーは Connection refused や Connection closed by ...。サーバーが落ちているわけでもなく、別の IP からは普通に SSH できる。接続元 IP がサーバー側で一時的にブロックされている症状でした。
外部の調査レポート 2 本で原因が見えてきました。サーバー側の保護機構(fail2ban や OpenSSH 25.0 以降の PerSourcePenalties)が、短時間の認証失敗集積を検知して接続元 IP を一時ブロックする仕様です。ただ、接続テストは 2-3 回しか押していない。なぜそんなに「失敗が集積」していたのか?
答えは paramiko の既定挙動 にありました。
paramiko の既定 — 1 接続で何本も鍵を試す
paramiko の SSHClient.connect() は既定で次の 2 つが True になっています:
client.connect(
'host',
pkey=my_key,
# 以下は既定値(明示しなくても True)
# look_for_keys=True, # ~/.ssh/id_* も自動試行
# allow_agent=True, # ssh-agent 登録鍵も自動試行
)
これは、明示した pkey で失敗したときに ssh-agent 登録鍵 → ~/.ssh/id_* ファイル → password auth まで順に試行する挙動です。1 鍵だけ持っている開発者には便利な「フォールバック」設計ですが、多サイト管理者環境ではこれが裏目に出る:
- SSH エージェントには複数のサイト用鍵が登録されている
~/.ssh/にもid_rsa/id_ed25519等が複数置かれている- 1 接続で 5-10 個の鍵を順番に試行することになる
- サーバー側の
MaxAuthTries(既定 6)を 1 接続で超過する
つまり、ユーザーから見て「接続テスト 1 回」のつもりが、サーバー側からは「短時間に 5-10 回の認証失敗を起こした怪しい IP」として認識されていました。これが 2-3 回繰り返されれば、保護機構が「閾値超え」と判定して IP ブロックを発動する流れです。
修正 — look_for_keys=False / allow_agent=False で鍵試行範囲を限定
paramiko には鍵試行範囲を絞るオプションが用意されています。connect_kwargs で明示的に切ります:
connect_kwargs = {
'pkey': my_key,
'look_for_keys': False, # ~/.ssh/id_* を自動試行しない
'allow_agent': False, # ssh-agent 登録鍵を自動試行しない
}
client.connect('host', **connect_kwargs)
これで「明示した pkey だけを試行 → 失敗したら即終了」になり、1 接続あたりの認証試行回数は 1 回に確定します。MaxAuthTries を超える経路は消えました。
後方互換性は保たれます。明示 pkey / key_filename 利用者の挙動は変わらず、パスワード認証ユーザーへの影響もなし。
V12 の教訓を活かして 10 箇所同時修正
ここで効いたのが、bash 構文の SSH コマンドが csh で詰まる横断バグ で確立した「同型バグは初回修正時に横断 grep する」原則です。Connection(...) 呼び出しを全件洗い出すと、10 箇所も connect_kwargs 未指定または空の状態で SSH 接続を貼っていました:
| 修正箇所 | 役割 |
|---|---|
core/ssh_utils.py::get_ssh_connection |
メンテナンス本体経路 |
save_server_profile 内 WP-CLI 自動検出 |
プロファイル保存時 |
test_ssh_profile |
プロファイル接続テスト |
discover_server_paths |
パス自動検出 |
test_wpcli |
WP-CLI テスト |
install_wpcli |
WP-CLI 自動インストール |
diagnose_server |
サーバー診断 |
fetch_plugins |
プラグイン一覧取得 |
fetch_pending_plugins_for_site |
更新待ちプラグイン取得 |
save_site 内 WP-CLI auto-detect |
サイト保存時 |
全箇所に「IP 一時ブロックを構造的に防ぐ」根拠コメントを併記しました。1 箇所だけ直していたら、別の API 経由で同じ問題が再発していたはずです。
警告 UI も同時に撤去 — 自動リトライと組み合わさると逆効果
実は以前のラウンドで、別のアプローチでこの問題に対処しようとしていました: 接続テスト前に 秘密鍵パーミッションを診断 → 緩ければ警告 + 「修正してから接続」ボタンを出す UI です。良かれと思った設計でしたが、今回の事象では完全に裏目に出ていました。
ユーザーから見るとこんな流れになります:
- 警告「秘密鍵パーミッションが緩いです。修正しますか?」
- 「修正してから接続」を押す → 内部で
chmod 600→ 接続テスト自動再実行 - 再実行も同じ複数鍵試行 → 失敗
- 「Authentication failed」 → 別の鍵を試そうと再試行 → さらに失敗
- 失敗集積で IP ブロック発動
「警告で気づかせる + 自動で直す + 自動再試行」というデザインは、根本原因が別にある場合、自動リトライが失敗回数を増幅させる 効果を持ちます。
しかも、paramiko は OpenSSH の StrictModes チェックを行わないので、本アプリの文脈ではそもそも秘密鍵パーミッションが緩くても動作します。「あった方が親切」と思っていた予防警告は、本アプリには過剰でした。
このラウンドで警告 UI 本体は撤去(-120 行 / +31 行 = 正味 89 行削減)。_diagnoseAndOfferFix() 関数のシグネチャだけ後方互換で残置し、本体は no-op に縮約しました。
回帰防止 — AST 静的解析テストで「空の connect_kwargs」を禁じる
V12 と同じ二段防御のパターンで、回帰防止テストを追加しました。tests/test_ssh_connection_isolation.py に 6 テスト:
# 概念図
import ast
def test_all_connect_kwargs_have_look_for_keys_false():
"""すべての connect_kwargs / ck 初期化に
'look_for_keys': False / 'allow_agent': False が
含まれることを AST で検証。"""
for file in [CORE_SSH_UTILS, SITE_MANAGER_WEB]:
for assign in find_connect_kwargs_assignments(file):
keys = extract_dict_keys(assign.value)
assert keys.get('look_for_keys') is False, \
f"{file}:{assign.lineno} missing look_for_keys=False"
assert keys.get('allow_agent') is False, \
f"{file}:{assign.lineno} missing allow_agent=False"
将来、誰かが新しい SSH API を追加して connect_kwargs = {} を空のまま使うと、CI で落ちます。assign.value を AST から直接読むため、コメントや docstring 内の文字列に騙されない設計です。
学び — 3 つの原則
- ライブラリの既定挙動は環境前提が違う場面で「正しくないこと」がある — paramiko の
look_for_keys=True/allow_agent=Trueは単一鍵ユーザーには合理的な「フォールバック」だが、多鍵環境では危険な挙動になる。既定を信じる前にライブラリの doc を「自分の運用環境前提で」読み直す - 警告 UI + 自動リトライの組合せが事態を悪化させ得る — 「警告で気づかせる + 自動で直す + 再試行」は良かれと思った設計でも、根本原因が別にあると自動リトライが失敗を増幅する。UX レイヤーでの「親切」が運用レイヤーで負債になることがある
- 同型バグは初回修正時に横断 grep + 静的解析テスト — 同型バグの第 3 弾。
/bin/sh -cラップ・_safe_runヘルパー(V12)と同じ構造で、connect_kwargsも全 SSH 接続経路で揃える必要があった。横断 grep + AST 静的解析テストの二段防御を標準ワークフローに組み込んでおくと安心感が違う
SSH 系コードは「ライブラリの既定が環境前提で外れる」「同じパターンが複数経路に散らばる」性質を持ちやすい場所です。多サイト管理者向けのツールを書くなら、paramiko 既定の look_for_keys / allow_agent は最初から False にしておいて損はない、というのが今回の結論でした。