Linuxの対話がめんどくさい?そんな時こそ自動化だ!-expect編-

  • 417
    Like
  • 2
    Comment

expect編以外に書くつもりは毛頭ありません。
manを見てもググってもどうにも手がつけられない程度に分かりづらかったので、備忘録としてまとめます。

expectって何?

対マシンに於いて発生する、マシンからの問いに対し、人間がキーを叩いて返答する、所謂対話を自動化するコマンド及びモジュールです。
今回はLinux上のexpectコマンドを取り上げますが、CでもC++でも同様の処理が実行可能なようです。
RubyやPerlでも書き換えられ、モジュールとして配布されているようです。

元はTclというプログラム言語ベースのコマンドです。
コマンドとは言いましたが、使い道はほぼスクリプトとなるかと思います。
というかコイツをワンライナーでどう使えばいいのかよくわかりません。
また、expectはコマンドというか言語であるようです。
expectはTclのスーパーセット(Tclを包括し、更にオプションを加えたようなものでしょうか)であり、expectスクリプト内でTclが利用できます。
expectはyumやapt-getでインストール出来ると思います。

expectの使い方

例えば、 passwd コマンド。
これはユーザのパスワードを変更するコマンドですが、必ず対話が発生します。

Ex)passwd
$ passwd hoge
Changing password for user hoge.
New password:
Retype new password:
passwd: all authentication tokens updated successfully.

オプションも何も付けず普通に使う場合には、上記のように必ず対話が発生します(--stdinオプションなんか付ければ話は違うかも知れませんが・・・)。

また、例えば ssh でも同様です。

Ex)ssh
$ ssh 192.168.0.1
The authenticity of host '192.168.0.1 (192.168.0.1)' can't be established.
ECDSA key fingerprint is d3:8f:4f:46:04:2f:ea:5b:ad:fd:bb:c5:7a:35:56:67.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '192.168.0.1' (ECDSA) to the list of known hosts.
root@192.168.0.1's password:
Last login: Tue Jul  7 22:02:08 2015 from 192.168.0.2
$ 

パスワード認証である限り、SSH接続時にはどうしても対話せざるを得ません。
認証が一度だけならまだしも、幾つかのサーバを踏み台にする場合や、一台の管理サーバから幾つかのサーバの情報を引っ張ってきたい場合には、パスワード認証だと少し面倒です。
手間も掛かりますし、疲れます。
こういった対話を自動化するのがexpectなのです。

SSHを自動化する

実際に ssh コマンドを自動化するexpectのスクリプトを書いてみます。

ssh-exp.sh
#!/bin/sh

PW="Password"

expect -c "
set timeout 5
spawn env LANG=C /usr/bin/ssh hoge@ServerName
expect \"password:\"
send \"${PW}\n\"
expect \"$\"
exit 0
"

簡単に書くと上記のようになるかと思います。
今回はシェルスクリプトとして書き、その中でexpectを呼び出しています。
expect単体でも書けるのですが、expectの解説をしている他の技術系エントリなんかを見ると、大体この書き方を取り上げているので、こちらの例も書いておこうと思った次第です。


上記のスクリプトで利用しているexpectのコマンドは以下の5つです。

コマンド 説明
set timeout デフォルトのタイムアウトする秒数を指定します。
指定しない場合は10秒となっています。
expect expectスクリプトで、マシンからの応答を読み取り、パターンマッチをする時に利用します。
パターンマッチはswitch文、case文と同様の処理が実現できます。
spawn expect内でプロセスを生成するコマンドです。
send マシンに文字列を返答するコマンドです。
exit expectの処理を終了させ、返り値として指定した数字を返します。


シェル部分は省略して、expectに関連するところを上から順番に解説していきましょう。

expect -c "
set timeout 5

シェルの中でexpectを動かす場合は、-c オプションを付けます。
オプションを付けたうえで、expectで処理する内容を "(ダブルクォーテーション) で囲みます。
次に、 set timeout 5 と宣言しています。
これは「expectの処理中に5秒経過しても応答がない場合強制的に処理を中断しますよ」という意味になります。
デフォルトでも10秒で設定されていますが、少し長いので半分の5秒で設定しました。
本来はexpectの自動化から一時的に対話式に戻した場合に設定するようです。

spawn env LANG=C /usr/bin/ssh hoge@ServerName

sshコマンドで、ユーザ hoge で、ServerNameにログインします。
spawnはexpect内で使用できるコマンドで、expectの処理の中で新しいプロセスを生成します。
spawnで開始したプロセスの標準入力と標準出力はexpectに結びつけられ、expect内のコマンドで読んだり書いたりできます。
単純に言ってしまえば、spawnで流すコマンドがexpectの自動化の対象になります。
今回はsshコマンドを自動化するので、それをspawnで流しています。
フルパスだったり、頭に LANG=C と付いていたりしますが、ここらの話は本筋から脱線するので、省略します。

expect \"password:\"
send \"${PW}\n\"

さて、メインの処理です。
「expectの中でまたexpect?」と思うかも知れませんので、説明します。
expect -c は、「これからexpectの処理を開始しますよー」という宣言をしています。
処理中に更に宣言しているexpectは spawnで実行したコマンドの応答を取得し、用意した文字列と比較して真であった場合に処理を続行させる という意味になります。
こうした違いがあるため、manではexpectプログラムそのものを Expect と表記し、プログラムに実装されているexpectコマンドは expect と表記しています。
今回のエントリ内でも、以降は区別をするために、上記の表記で書いていきます。
さて、応答と文字列を比較して真であった場合に処理を続行させるのがexpectです。

今回は password: という文字列と比較し、一致していれば処理を続行し、PW変数に定義されている文字列を返します。
完全一致である必要はなく、部分一致で構いません。
また、上記のexpectコマンドでは "(ダブルクォーテーション) の前に \(バックスラッシュ) を入れています。
これはexpect -c のダブルクォーテーションとは別のダブルクォーテーションだと認識させる為に挿入しているものです。
シェルでExpectを動かす場合、 expect -c でExpectの処理全体をダブルクォーテーションで処理を囲まなければならないのですが、Expect処理内のexpectコマンドでも、文字列を指定する場合は、ダブルクォーテーションで文字列を囲まなければなりません。
バックスラッシュをそのまま記載してしまうと、 expect -c で宣言したダブルクォーテーションの終わりを指してしまうので、そのエスケープとして必要になります。

expect \"\\\$\"
exit 0
"

 
上記も同じようなものです。
$ という文字列と比較し、一致していれば、 exit で返り値を0に指定し処理から抜けます。
今回 $ はプロンプトになりますね。
バックスラッシュを三つ続けて入れているのは、こうしないと $ がちゃんとエスケープされないためです。
特殊文字だからかはわかりませんが、私の環境ではバックスラッシュ一つでは上手く動作しませんでした。
最後のダブルクォーテーションは expect -c の処理を閉じるためのものです。

という事で、sshコマンドが自動化できました。

しかしこれを見ても、多分腑に落ちないと思います。
「シェルでしか使えないの?」
「初めてssh接続した時はフィンガープリントのYes/Noが出るけどその処理は?」
「予期しない処理が起きた時はどうなるの?」
「なんかお粗末」
「全体的にソースが汚い」

確かに、これでは最低限の事しか出来ません。
予め決まっている一本道の処理以外には全く対応できません。
では、もう少し改良してみましょう。

SSHを自動化する(改良版)

まずExpect単体で実行できるようにし、雑多なコード群をプログラム言語っぽくしてみます。
フィンガープリントのyes/noも自動化します。1

ssh.exp
#!/usr/bin/expect

set PW "Password"

set timeout 5

spawn env LANG=C /usr/bin/ssh hoge@ServerName
expect {
    "(yes/no)?" {
        send "yes\n"
        exp_continue
    }
    "password:" {
        send "${PW}\n"
    }
}

expect {
    "\\\$" {
        exit 0
    }
}

いかがでしょうか。
幾らかプログラムっぽくなりましたね。
それでは、上から順に解説していきましょう。

#!/usr/bin/expect 

まずこちら、ご存知シバンです。
シェルスクリプトに限らず、LinuxでPerlやRubyを書いた事がある方ならよくご存知かと思います。
Expectではシバンが使えます。
この一行で、「今から書くスクリプトのインタプリタはExpectですよ」というのを宣言しています。
これは expect -c と同等のものです。

set PW "Password"

set timeout 5

次に、PW変数に、パスワードとして設定したい文字列を代入しています。
最初の方でも触れましたが、ExpectはTclのスーパーセットです。
その為、変数の宣言もTclに準拠したものを使います。
その下のtimeoutは、シェルで書いたものと同じ意味です。

spawn env LANG=C /usr/bin/ssh hoge@ServerName
expect {
    "(yes/no)?" {
        send "yes\n"
        exp_continue
    }
    "password:" {
        send "${PW}\n"
    }
}

次に、メインの部分です。
spawnは同じですが、その下は書き方が違いますね。
シェルで書いたものをわかりやすくし、フィンガープリントの処理に対応させました。

まず、一つのexpectの処理にどのパターンが対応しているのか明確にするために、expectが対応している部分を{}で囲みます。
次に、パターンマッチとして (yes/no)?password: の二つを指定しています。
これは上の説明でも書いた通りですが、expectのパターンマッチはswitch文、case文と同様の処理が実現できるので、複数パターンを一つのexpectに入れる事ができます。
そして、各パターンに対応するアクションを更に {} で囲みます。
これもパターンに対するアクションを明確にするためです。

(yes/no)? のパターンにマッチすれば、文字列 yes を応答として返します。
その下に exp_continue というコマンドを置いています。
これはmanによると、「expect 自身に待っていた値が来なかった時のように、expect の実行を続ける。」そうです。
今回のスクリプトに照らし合わせて言うと、 (yes/no)? パターンにマッチして、該当するアクションを実行したとしても、expectから抜けることなく、その下にある password: のパターンマッチ処理を続けることができます。
expectは、(先述しましたが)switch文やcase文のようなものです。
幾つかのパターンを定義し、その内どれかにヒットすれば宣言したアクションを実行できますが、アクション実行後はそのexpectから抜けて、次の処理に移行してしまいます。
exp_continueをアクション部に宣言しておけば、そのアクションを実行してもexpectから抜けずにexpect内の処理を続行することができます。
長くなってしまいましたが、そういうことです。

次の password: にマッチすれば、変数PWの内容を返します。変数の指定の仕方はシェルと同様です。

expect {
    "\\\$" {
        exit 0
    }
}

上記も同じですね。
この場合、パターンマッチもアクションも一つしかありませんが、それぞれ分かりやすいように{}で囲んでいます。

さて、幾らかプログラムっぽくなってきました。

しかし、まだまだ足りません。
更に追加改良を加えられます。

SSHの自動化を改良する

もう少し改良してみましょう。
とりあえず以下の点をどうにかします。

  • 実行時のログをログファイルに記録する
  • 複数のプロンプトに対応できるようにする
  • ホストとパスワードを引数から読み込めるようにする
  • パターンマッチの表現を明確にする
  • 頭にハイフンが付いているパスワードが入力されてもちゃんと処理されるようにする
  • SSH接続をそのまま留めるようにする

これら全部を実現します。

ssh.exp
#!/usr/bin/expect

log_file /var/log/expect.log

set RemoteHost [lindex $argv 0]
set PW [lindex $argv 1]
set Prompt "\[#$%>\]"

set timeout 5

spawn env LANG=C /usr/bin/ssh ${RemoteHost}
expect {
    -glob "(yes/no)?" {
        send "yes\n"
        exp_continue
    }
    -glob "password:" {
        send -- "${PW}\n"
    }
}

expect {
    -glob "${Prompt}" {
        interact
        exit 0
    }
}
使用法
[user@srv ~]$ ssh.exp user@remote password

[user@remote ~]$ 

実行時のログをログファイルに記録する

下記を追加します。

log_file /var/log/expect.log

Expectにはlog_fileコマンドがあり、log_file ファイル名 でログが取得できます。
オプションを付けない場合は、Expectを実行する度にログに追記されていきます。
-noappend オプションを付けると、ログを流す度に上書きがされます。

複数のプロンプトに対応できるようにする

変数に複数プロンプトを宣言しておきます。

set Prompt "\[#$%>\]"

変数の場合でも正規表現のような[]での展開ができます。
その際バックスラッシュが必要になります。
今回の場合、#, $, %, > にマッチするようにしています。

ホストとパスワードを引数から読み込めるようにする

大きな変更点は以下です。

set RemoteHost [lindex $argv 0]
set PW [lindex $argv 1]

引数を使う場合も、Tclの記法に準拠します。
引数は [lindex $argv n] で利用できます。
[lindex $argv 0] は一つ目の引数を表します。 [lindex $argv 1] は二つ目の引数です。
読み込んだ引数をそれぞれ変数に入れています。

spawn env LANG=C /usr/bin/ssh ${RemoteHost}

SSHで繋げる先のホストを変数にしています。
変更点はこれだけです。

パターンマッチの表現を明確にする

変更点は以下です。

-glob "(yes/no)?"

-glob "password:"

パターンマッチとして指定している文字列の前に -glob というオプションを追加しています。
これは、「パターンマッチにglobルールを使いますよ」と宣言するオプションです。
パターンマッチには、主に以下のようなルールが使用できます。

オプション パターンマッチのルール
-glob globを使います。デフォルトではこれが指定されています。
-regexp 正規表現を使います。
-exact globや正規表現の展開方法を使わず、基本的に文字をそのまま解釈します。
Tclの変数展開は利用可能です。

頭にハイフンが付いているパスワードが入力されてもちゃんと処理されるようにする

変更点は以下です。

send -- "${PW}\n"

send の後に、ハイフンを二つ付けたオプションを置いています。
これを追加することで、頭にハイフンが付いているパスワードが入力されても、ちゃんと処理されるようになります。
試しにこれ以前のスクリプトでSSHのログイン先ユーザーのパスワードを -Password などに変更した上でSSHログインを実施してみてください。
恐らく何度試しても弾かれてしまうかと思います。
これはExpectの仕様で、"-"で始まる文字は全て将来のオプションとして予約されているためです。
その為、普通の文字列でも頭にハイフンが付いていると、全てオプションとして認識されてしまいます。
これを抑止するのが -- オプションです。

SSH接続をそのまま留めるようにする

変更点は以下です。

expect {
    -glob "${Prompt}" {
        interact
        exit 0
    }
}

interact というコマンドが追加されています。
これは プロセスの制御をユーザーに渡す コマンドです。
ユーザーからの標準入力を受け付けるようになります。

という事でできました。おめでとうございます。
大体この辺りまで書けるようになれば漸くExpect初心者といったところでしょうか。

Expectのデバッグ方法

そう言えばデバッグ方法を書いていませんでした。
デバッグは以下の二通りで可能です。

  • スクリプト実行時に -d オプションを付ける
  • exp_internalコマンドを使う

スクリプト実行時に -d オプションを付ける

早い話がこういう事です。

$ expect -d Script.exp

こうすることで、詳細なデバッグログが出力されます。
以下のようにシバンに書くことでもデバッグログの出力が可能です。

#!/usr/bin/expect -d

exp_internalコマンドを使う

これはスクリプト内に追記する形になります。

exp_internal 1

これで、宣言したところ以降のデバッグログが出力されます。

Expect内でLinuxのコマンドを実行する方法

やるとしたら下記の二通りかなと思います。

  • ローカルホストでコマンドを実行
  • SSHの接続先でコマンドを実行

どちらも可能です。

ローカルホストでコマンドを実行する

こちらは簡単です。
以下をスクリプトの任意の場所で宣言すれば、exec以降のコマンドが実行されます。

puts [exec date]

対話以外のコマンドはspawnでは上手く動かない場合があり、manでも「通常のコマンドはexecを使うようにしてほしい」という旨が書いてあるので、上記のように書くのがベストでしょう。

SSHの接続先でコマンドを実行する方法

SSHの自動化スクリプトを修正したものを使って解説します。

#!/usr/bin/expect

log_file /var/log/expect.log

set RemoteHost [lindex $argv 0]
set PW [lindex $argv 1]
set Prompt "\[#$%>\]"

set timeout 5

spawn env LANG=C /usr/bin/ssh ${RemoteHost}
expect {
    "(yes/no)?" {
        send "yes\n"
        exp_continue
    }
    -re "password:" {
        send -- "${PW}\n"
    }
}

expect {
    -glob "${Prompt}" {
        log_user 0
        send "date\n"
    }
}

expect {
    -regexp "\n.*\r" {
        log_user 1
        send "exit\n"
        exit 0
    }
}

変更点はexpectの部分ですね。
順に解説していきます。

expect {
    -glob "${Prompt}" {
        log_user 0
        send "date\n"
    }
}

プロンプトが返ってきたら、まず log_user 0で、標準出力への出力を抑止します。
次に send でコマンドを渡します。
改行(\n)を忘れないようにしましょう。

expect {
    -regexp "\n.*\r" {
        log_user 1
        send "exit\n"
        exit 0
    }
}

ここがキモです。
改行(\n)を挟んで復帰(\r)まで全ての文字にマッチするようにします。
何故このパターンマッチとするか、順を追って説明していきます。

まず、マッチを -regexp ".*" として、デバッグログを確認してみます。

send: sending "date\n" to { exp5 }
Gate keeper glob pattern for '.*' is ''. Not usable, disabling the performance booster.

expect: does " " (spawn_id exp5) match regular expression ".*"? (No Gate, RE only) gate=yes re=yes
expect: set expect_out(0,string) " "
expect: set expect_out(spawn_id) "exp5"
expect: set expect_out(buffer) " "

expect: does " " (spawn_id exp5) match regular expression ".*"? (No Gate, RE only) gate=yes re=yes がマッチした部分です。
スペースを取ってきてしまっていますね。
どういう訳かはよくわかりませんが、sendで文字列を送ると頭にスペースが入ってしまうようです。
そのスペースと一致してしまっているようです。

では、少し変更します。
欲しいのはsendコマンドでLinuxコマンドの結果です。
sendで送る時、Linuxコマンドの後ろで改行(\n)をしているので、パターンマッチを -regexp "\n.*" に修正してみます。

expect: does " " (spawn_id exp5) match regular expression "\n.*"? Gate "\n*"? gate=no
date

expect: does " date\r\n" (spawn_id exp5) match regular expression "\n.*"? Gate "\n*"? gate=yes re=yes
expect: set expect_out(0,string) "\n"
expect: set expect_out(spawn_id) "exp5"
expect: set expect_out(buffer) " date\r\n"

sendで送った "date" は出力されるようになりました。
コマンドの結果は出力されていません。

ではどうしましょう。
注意深くみると、 expect: does " date\r\n" (spawn_id exp5) match regular expression "\n.*"? Gate "\n*"? gate=yes re=yes となっていることがわかります。
文字列の後ろに復帰(\r)改行(\n)が付いています。
他のデバッグログを確認しても、文字列は出力時に末尾に必ず\r\nが付いています。
ということで、-regexp "\n.*\r" に修正してみます。

expect: does " " (spawn_id exp5) match regular expression "\n.*\r"? Gate "\n*\r"? gate=no
date

expect: does " date\r\n" (spawn_id exp5) match regular expression "\n.*\r"? Gate "\n*\r"? gate=no
Sat Aug  8 14:47:30 JST 2015

expect: does " date\r\nSat Aug  8 14:47:30 JST 2015\r\n" (spawn_id exp5) match regular expression "\n.*\r"? Gate "\n*\r"? gate=yes re=yes
expect: set expect_out(0,string) "\nSat Aug  8 14:47:30 JST 2015\r"
expect: set expect_out(spawn_id) "exp5"
expect: set expect_out(buffer) " date\r\nSat Aug  8 14:47:30 JST 2015\r"

expect: does " date\r\nSat Aug 8 14:47:30 JST 2015\r\n" (spawn_id exp5) match regular となっています。
しっかり出力されています。

というわけで、SSHの接続先でコマンドを実行できました。
dateだけでなく色々なコマンドが実行できます。
ぜひ色々試してみてください。

というわけでExpectは以上です。
グダグダな感じなので後で別途簡潔にまとめたいなあ・・・。


  1. sshコマンドは -o StrictHostKeyChecking=no オプションを指定することで、フィンガープリントの警告を回避できるため、expectで自動化しなくても大丈夫です(2017/01/11追記: コメントにて教えていただきました。ありがとうございます)。