ベンダーのVPNクライアントを起動するスクリプトがタイムアウトまで止まり、ログには誰も入力していない確認コードをクライアントが求めていると出る。パスワードは渡していたので、パスワードが間違っているという結論は明白に見える。そうではない。コードの要求は別チャネルでの別の対話であり、スクリプトはそれを一度も開いていない。

以下の五つの確認を順に進める。最初に失敗した確認で止めるのは、以降の手順がそれ以前の成立を前提にしているからだ。

確認1: クライアントは標準入力を読むのか。

自動化を書く前に試す。ダミーの秘密をパイプでクライアントに渡して何が起きるかを見て、プロンプトを表示してから待つなら、パイプは読んでいない。

多くのクライアントは端末デバイスを直接開くか、端末以外のストリームから秘密を受け取ることを拒み、迷い込んだパイプがパスワードを漏らさないようにしている。OpenVPNは文書化された例外で、--auth-user-passがファイルパスを受け取る。ワンタイムコードを求めるベンダーのクライアントには同等のフラグがないことが多く、そのプロンプトはスクリプトが用意しなかった端末を待っている。

確認2: 子プロセスに端末はあるか。

ptyは子に端末を割り当て、プロンプトを読み取れる出力に変えるので、順番に応答できる。

import pexpect

child = pexpect.spawn("openconnect vpn.example.net", encoding="utf-8", timeout=30)
child.expect("GROUP:")
child.sendline("staff")
child.expect("USERNAME:")
child.sendline("alice")
child.expect("PASSWORD:")
child.sendline(PASSWORD)
child.expect("Connected as", timeout=60)

上のプロンプト文字列は例示であり、クライアントが実際に出力する正確な文字列を大文字小文字と記号まで含めてコピーする。どのexpectにもタイムアウトを付け、閉じた子はステータスを返さず例外を投げるので、全体を包んでファイル終端を失敗として扱う。このブロックが子を切り離さないのは、ptyが切り離しの仕組みではなく、disownもシェルの組み込みコマンドでスクリプトが起動したプロセスには届かないためであり、子はスクリプトと一緒に死ぬ。

確認3: 対応する非対話の入力はあるか。

画面スクレイピングはクライアントの更新で壊れるので、まず文書化されたフラグを探す。

ヘルプ出力にトークンやワンタイムコードのオプションがないか確認し、パスワード欄に追記したコードをサービスが受け付けるかも確認する。クライアントが資格情報ファイルを読む場合、プロンプトではなくファイルを渡す:

openvpn --config office.ovpn --auth-user-pass <(printf "%s\n%s\n" "$USER_NAME" "$PASSWORD")

プロセス置換は、/dev/fdを提供するシステムでクライアントに読み取り可能なパスを渡す。秘密はクライアントから見れば依然として秘密なので、ログ、シェル履歴、プロセス一覧から遠ざける。クライアントが/dev/fdを拒否する場合や、資格情報のパスを二回以上開く場合は、厳しい権限の実ファイルに書き、同じスクリプト内で削除する。

確認4: トンネルはスクリプトより長く生きるか。

既定ではトンネルは親とともに死ぬので、起動スクリプトが必要とする性質とは逆になる。

disown -hが役立つのは、クライアントをシェルのジョブとして起動した場合だけであり、そのシェルのジョブテーブルを編集するもので、スクリプトがpty経由で作ったプロセスには届かない。無人の再接続が必要なものにはサービスマネージャーのほうが適しており、クライアントを再起動し、落ちた理由も記録する。いずれにせよ、ワンタイムトークンは最初の認証成功で消費されるため、再接続のたびに新しいコードを想定する。

sudoがこの順序に何をするかに注意する。sudoのパスワード要求はVPNの要求ではなく、そこにVPNのパスワードで答えるのが、正しいはずのexpect順序が失敗する最も一般的な原因だ。

確認5: 人のコードをスクリプトに置くべきか。

これは飛ばされがちな確認だ。トークンは人に属し、スケジュールで上がるべきトンネルは機械に属する。

無人で再接続する必要があるなら、マシン証明書やサービスアカウントで対話ログインを置き換えられるか問う。誰かのワンタイムコードを自動化することは、第二要素を保存された秘密に変えることであり、スクリプトの利便性ではなく方針の変更だ。サービスが両方を支えるなら、そのジョブの実際の動きに合う資格情報を選ぶ。答えを書き残すのは、次の人がなぜスクリプトがトークンを持つのかと必ず尋ねるからだ。

動くスクリプトの形

スクリプトは到達した手順を出力し、秘密は決して出力せず、expectに失敗したら非ゼロで終了する。

誤ったパスワード、誤ったグループ、期限切れのトークンと、わざと壊して試す。どの失敗も、固まるのではなく名前の付いた手順で止まるべきだ。そのうえで実際のサービスに対して一度実行し、トンネルをスクリプトの外から、中からではなく確認する。

関連記事