コンテンツへスキップ

paramiko の既定が IP ブロックを呼んだ — look_for_keys / allow_agent の罠と 10 箇所一括修正

ある日、多サイト管理者のユーザーから不可解な不具合報告が届きました。「アプリで接続テストを 2-3 回繰り返したら、その後 その IP からの SSH が長時間繋がらなくなる 」。エラーは Connection refusedConnection 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 です。良かれと思った設計でしたが、今回の事象では完全に裏目に出ていました。

ユーザーから見るとこんな流れになります:

  1. 警告「秘密鍵パーミッションが緩いです。修正しますか?」
  2. 「修正してから接続」を押す → 内部で chmod 600 → 接続テスト自動再実行
  3. 再実行も同じ複数鍵試行 → 失敗
  4. 「Authentication failed」 → 別の鍵を試そうと再試行 → さらに失敗
  5. 失敗集積で 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 つの原則

  1. ライブラリの既定挙動は環境前提が違う場面で「正しくないこと」がある — paramiko の look_for_keys=True / allow_agent=True は単一鍵ユーザーには合理的な「フォールバック」だが、多鍵環境では危険な挙動になる。既定を信じる前にライブラリの doc を「自分の運用環境前提で」読み直す
  2. 警告 UI + 自動リトライの組合せが事態を悪化させ得る — 「警告で気づかせる + 自動で直す + 再試行」は良かれと思った設計でも、根本原因が別にあると自動リトライが失敗を増幅する。UX レイヤーでの「親切」が運用レイヤーで負債になることがある
  3. 同型バグは初回修正時に横断 grep + 静的解析テスト — 同型バグの第 3 弾。/bin/sh -c ラップ・_safe_run ヘルパー(V12)と同じ構造で、connect_kwargs も全 SSH 接続経路で揃える必要があった。横断 grep + AST 静的解析テストの二段防御を標準ワークフローに組み込んでおくと安心感が違う

SSH 系コードは「ライブラリの既定が環境前提で外れる」「同じパターンが複数経路に散らばる」性質を持ちやすい場所です。多サイト管理者向けのツールを書くなら、paramiko 既定の look_for_keys / allow_agent は最初から False にしておいて損はない、というのが今回の結論でした。