ruby expect でルータ操作

すごく前に作った perl-Expect なスクリプト を元に一括コンフィグバックアップスクリプトとか使ってたんですが、

  • 適当に作ったから汎用性がない
  • 最近ぜんぜん perl 使ってないからかなり忘れてしもた

という有様で。したがって機能追加とかすごくしにくい状況になったので、どうせだったら ruby で作り直してみようか、と思ったのでありました。あと、Cisco AutoInstall 使えるようにしたときに作った初期コンフィグ生成スクリプトperl で、ホストの情報とか別々に管理する状態になってたのが煩わしかったので、ついでにその辺もまとめて統合する方向で。

ホスト情報

こんな感じでホストの情報を持っておきます。

- :hostname : 'r000'
  :type : 'C1812J'
  :ipaddr : '192.168.7.101'
  :mgmtif : 'Fa0'
  :mgmtifmac : '0015.2b3f.623a'
  :protocol : 'telnet'
  :username : 'stereocat'
  :password : 'p@ssw0rd'
  :enable : 'p@ssw0rd'
- :hostname : 'r001'
  :type : 'C1812J'
  :ipaddr : '192.168.7.102'
  :mgmtif : 'Fa0'
  :mgmtifmac : '001f.6c1d.cedc'
  :protocol : 'telnet'
  :username : 'stereocat'
  :password : 'p@ssw0rd'
  :enable : 'p@ssw0rd'

単純に expect で各ルータに対してコマンド実行させるだけなら IP 情報だけあればいいんだけど、autoinstall で使う用の初期コンフィグ生成のための情報も持たせてあるのでアレコレ追加してあります。(そっちの話は今回はナシ)

Expect

スクリプト

とりあえずこんな感じにしてみた。

#!/usr/bin/ruby

require 'pty'
require 'expect'
require 'yaml'
require 'erb'

# options
if ARGV.size >= 2 and File.file?(ARGV[0]) and File.file?(ARGV[1])
  params = YAML.load_file(ARGV[0])
  public_cmd_list = YAML.load_file(ARGV[1])
else
  puts "#{$0} <hostlist.yml> <cmdlist.yml>"
  exit
end

# debug
$expect_verbose = true

params.each do |param|
  case param[:protocol]
  when /telnet/i
    cmd = ['telnet', param[:ipaddr]].join(' ')
  when /ssh/i
    cmd = ['ssh', '-o StrictHostKeyChecking=no', '-i',
           param[:username], param[:ipaddr]].join(' ')
  else
    puts "unknown protocol #{param[:protocol]}"
    exit
  end

  pass_prompt = '^Password\s*:'
  user_prompt = '^Username\s*:'
  sub_prompt1 = '\][:\?]'
  sub_prompt2 = '\[confirm\]'
  yn_prompt = '\[yes\/no\]:'

  hostname = '[\w\-]+'
  prompt1 = '^' + hostname + '>'
  prompt2 = '^' + hostname + '(:?\(config\))?\#'

  puts "# run expect: #{cmd}"
  timeout = 60

  PTY.getpty(cmd) do |reader, writer, pid|
    writer.sync = true

    puts "\n## start host #{param[:hostname]}\n"
    cmd_list = public_cmd_list.dup # copy list

    while(reader.eof? == false)
      enabled = false # flag
      reader.expect(
        %r!
          ( #{pass_prompt}
          | #{user_prompt}
          | #{prompt1}
          | #{prompt2}
          | #{sub_prompt1}
          | #{sub_prompt2}
          | #{yn_prompt}
          )$
        !x, timeout) do |match|
        if match
          case match[1]
          when /#{pass_prompt}/i
            if(enabled)
              writer.puts param[:enable]
            else
              writer.puts param[:password]
            end
          when /#{user_prompt}/i
            writer.puts param[:username]
          when /#{prompt1}/
            writer.puts 'enable'
            enabled = true
          when /#{prompt2}/
            if cmd_list.length > 0
              cmderb = ERB.new(cmd_list.shift)
              writer.puts cmderb.result(binding)
            else
              puts "\n### break\n"
              writer.puts 'exit'
            end
          when /#{yn_prompt}/
            # must mutch before sub_prompt
            writer.puts 'yes'
          when /#{sub_prompt1}/, /#{sub_prompt2}/
            writer.puts ''
          end
        else
          break
        end

      end # expect
    end # while

    puts "\n## end host #{param[:hostname]}\n"

  end
  puts "# finish expect: #{cmd}"

end

やれること

  • login/password の自動入力〜自動的に enable mode に入るところまで。
  • とりあえず telnet する分にはコレで問題ない。ssh にも対応…できるはず。いまテスト環境で ssh enabled な機材を動かしてないのでちゃんとテストできてないけど。

やれないこと

  • 実験環境で使う想定だったのでセキュリティは考慮していません。パスワードをべた書きしてます。良くはないけどね…
  • あらゆるエラー処理は全く考慮していません。
つかいかた

ホスト情報とコマンド情報の YAML ファイルを食わせるだけ。

stereocat@lambda02:/tftpboot$ cat save_config.yml
- 'copy run start'
- 'copy run tftp://192.168.7.16/'
stereocat@lambda02:/tftpboot$
stereocat@lambda02:/tftpboot$ ./run_expect.rb test_hostlist.yml save_config.yml
# run expect: telnet 192.168.7.101

## start host r000
Trying 192.168.7.101...
Connected to 192.168.7.101.
Escape character is '^]'.

User Access Verification

Password:
r000>enable
Password:
r000#copy run start
Destination filename [startup-config]?
Building configuration...
[OK]
r000#copy run tftp://192.168.7.16/
Address or name of remote host [192.168.7.16]?
Destination filename [r000-confg]?
!!
3167 bytes copied in 1.852 secs (1710 bytes/sec)

r000#
### break
exit
Connection closed by foreign host.

## end host r000
# finish expect: telnet 192.168.7.101
stereocat@lambda02:/tftpboot$

コマンドファイルについては、ERB でパラメタ埋め込みとかしてもよし。

- 'conf net tftp://192.168.7.16/<%= param[:hostname] %>.confg'

みたいに書いておくと、tftpサーバ上の ["ホスト名".confg] を自動的に読み込んでくれます。hostlist.yml の情報を元にホスト別のコンフィグを別途スクリプトで生成して一気に反映させたいとかもできるようにしてみました。

本当は環境情報とかも(例えば tftp server のアドレスとか)別途設定ファイル化してコマンドリストのファイルに直書きしないように作った方が良かったかな…。まあそこまで汎用化する必要があるかという話になるんだけど。

はまったこと

IO::Expect使い方がよくわからん

サンプルとか少ないんだよね…。そもそも条件分岐の方法とかがわからなかったし。

Expect あるある

プロンプトの正規表現が甘くて変なところを拾っちゃうケースがなあ…。例えば上に書いた conf net コマンドの場合

r000#conf net tftp://192.168.7.16/r000.confg
Host or network configuration file [host]?
This command has been replaced by the command:
         'copy <url> system:/running-config'
Address or name of remote host [192.168.7.16]? 
(略)

みたいな出力があるわけですが、プロンプト(prompt1)の設定が甘いと url> をプロンプトだと判断して変なコマンド送っちゃったりとか…。まあこの手のスクリプトではいつもこういうのがあるんだけど。


こういうのはまあよくあってですね。

  • プロンプト文字列の変化がベンダとか OS とかによって違うケース
    • /[Pp]assword\s*[:\?]?\s*$, 大文字小文字とか順番とかスペース有無とか記号有無とか…。いちいち違うんだよ…。
    • メッセージフォーマットが OS バージョンとかによって違うケースとかもね…。
  • 特にコンフィグを表示して設定情報とろうみたいな場合は結構面倒くさいことが。
    • Interface description, ACL remark, banner など、ある程度任意の文字や記号が使える物が悪さをするケース
    • モノにもよるけど、コマンドでプロンプト設定が可能な物で、プロンプト文字列がコンフィグ出力コマンドの中に含まれるような場合

などなど。まあクソ面倒くさいわけですよこの手の対話処理の自動化は。そんなわけで、netconf とか、リクエストとレスポンスが明確に分けられると言うだけでも使い勝手があるんだよね。たとえ通常の CLI コマンドを適当な XML でラップしておくって、通常の CLI コマンドの出力が適当な XML でラップされて返ってくる、というだけでも。コマンドの実行ステータスとレスポンスが明示的に分離できるってだけで expect ベースな対話処理よりは自動化実装は楽なんすよ。

……なんてことをこの SDN がどうのこうのといわれはじめているご時世にもかかわらずまだやってるってのがね。まあ API ベースで操作できるようにする、というのは高度な自動化とかオーケストレーションとかやる上では必然だと思いますよ。というのをこういうベタなスクリプト書いてると思うよね。

その他一般的な Expect のあれこれ

実装上の検討ポイントとして、汎用性をどこまで持たせるかというのがあるかなあと。例えば今回、サブプロンプトあるいは yes/no プロンプトについては固定で応答するようにしちゃってます。が、拡張 ping コマンドとか使おうとすると、サブプロンプトに対して適切な入力を返してやらないといけなかったりするので今の実装だとちゃんと動かない。

手っ取り早いのは、サブプロンプトも prompt2 扱いにして、コマンドのリストの方にサブプロンプト時の入力も全部書くって方向なんだけどね。そうなるとコマンドごとのサブプロンプト出力全部把握しないといけないのよ。まあ、自動応答だろうがそうでなかろうが基本的にはコマンドのサブプロンプト応答は全部把握しないといけないんだけどさ…。実装上、空応答(default)でスルーしてはいけないところだけをコマンドリストに入れておく、みたいな実装も考えられるわけですが、作るのと、後テストが面倒なんでね。その辺はやりたいこととか重要度に応じて選択です。


あとエラー処理そのものも全スルーしてます。

  • 誤ったコマンドを実行したとき/デバイス上のエラーの対処
  • expect サブプロセスのエラー
  • timeout など expect 実行上の(実装上の)エラー

なんかは本当はテストが要るよね。たとえば処理実行中にいきなりケーブル抜けたらどうなるんだろう、とか。仕事使うような場合には見る必要があるでしょう。この手のエラー処理はひどく対処が大変で……。中途半端に設定入れちゃった物を元に戻す → 戻し方は? 放置して先に進む → どこからはじめてどこまで進めるの? とかね。オペレーションには依存性があるわけで、他にも例えば、デバイス操作の排他制御とかどう考えるんだとかね。そういうのを踏まえて対応考えないといけない。そうなるとまずはちゃんと操作履歴残してそれをたどりましょう、という話が出てくるわけでして。ログとかどうするんじゃとか。いろいろ考えないとイカンわけです。無理です。

そもそも IO::Expect でやるものなのか?

CPAN 探せば Expect 拡張したようなモジュールはたくさんあるわけで、当然 rubygems.org にだって

みたいのがあるわけですね。こういうのがどこまで汎用性があるか、あるいは今やりたいことに対してどのくらいのコストで使えるようになるか、というのに応じて選択することになるでしょう。

他にも探せば、IO::Expect じゃなくてもうちょっと抽象度の高い Expect 的なモジュールとかもあるっぽいのでその辺を探した方が幸せになれるかもしれない。何かいいのがあったら教えてください。