rubyのNet::Telnetを使ってみた

## 背景
昨今話題のネットワーク自動化の波に乗るため、色々と摸索をしつつrubyのNet::Telnetを使ってみました。
巷ではNETCONFが盛り上がっているみたいですが、一方でまだまだtelnetとexpectを使った古の手法を捨てきれないと耳にします。そこで今回はrubyのNet::Telnetを用いて、ネットワーク機器へのconfig自動投入化をやってみようと思います。
間違いやイケてないコードの書きぶりなどなどご指摘いただけるととても喜びます。


# Net::Telnetについて

## 3行で
* telnetのクライアント機能を提供するrubyの標準ライブラリ
* SMTPやHTTPといったtelnet以外のプロトコルにも対応
* 詳しくは[リファレンスマニュアル](http://docs.ruby-lang.org/ja/2.2.0/class/Net=3a=3aTelnet.html)参照のこと

## 類似の技術
rubyで使えるtelnetクライアントには他に下記のようなものもあります

* pty + IO#expect
    * IOクラスのexpectメソッドと議事仮想端末インターフェースptyの合わせ技 
    * expectをガシガシ書いていくスタイル．大変そう
* ruby_expect
    * gem 
    * https://github.com/abates/ruby_expect 
    * 内部的にはPty.spawnを用いている模様
    * サンプルコードを見ると下記のようにシンプルにexpectが書けるらしい
    
    ```ruby
    expect "Change the root password? [Y/n]" do
      send "y"
    end
    ```

"confnigの自動投入"という観点ではNETCONFやSNMPも入りますが、そちらはただいま勉強中です。
他にもあればぜひご教授ください。

## 良いところ
Net::Telnetについていいなと思っている点を挙げてみます

* Net::Telnet.newの"Proxy"オプションで指定したオブジェクトを介した通信が可能
    * rancidとかが使える
    * これ自体はpty + IO#expectでもできますね
   
* Net::Telnet.cmdでコマンド文字列の引き渡しとwaitが1行でできる
    * cmdメソッドにブロックとして'Match'オプションを渡すことでコマンド実行後に待ち合わせができる
    * コマンド投入前後の確認に便利（下記コード参照）
    
* Net::Telnetクラスを拡張することで簡単にsyslogなどに出力可能
    * ロギング機能をもったcmdメソッドを新たに定義
    * コマンドと実行結果の確認及びログとして使えそう
    
* 書きやすい
    * ただの感想ですが、上記のブロック渡しcmdメソッドによってだいぶスッキリ書ける気がしています 


# Net::Telnetのコードを読んでみる
今までrubyのソースコードを読んだ経験はなかったのですが、せっかくの機会なので少しだけ覗いてみました。気になったところだけ掻い摘んで記載します。
    
まずinitializeメソッド

```ruby:telnet.rb
def initialize(options) # :yield: mesg
    #...略...
    # Proxyオプション指定がある場合
    if @options.has_key?("Proxy")
        if @options["Proxy"].kind_of?(Net::Telnet)
            @sock = @options["Proxy"].sock # Net::Telnetオブジェクトからsocketを得る
        elsif @options["Proxy"].kind_of?(IO)
            @sock = @options["Proxy"] # IOオブジェクトからsocketを得る
        else
            raise "Error: Proxy must be an instance of Net::Telnet or IO."
        end
    
    # Proxyオプションの指定がない場合
    else
        #...略...

        begin
            if @options["Timeout"] == false
                # TCPソケットを生成
                @sock = TCPSocket.open(@options["Host"], @options["Port"])
            else
                # Timeoutモジュールを設定: timeout(sec, exception_class = nil) {|i| ... } -> object
                Timeout.timeout(@options["Timeout"], Net::OpenTimeout) do
                    @sock = TCPSocket.open(@options["Host"], @options["Port"])
                end
            end
    #...略...
end
```
良いところの1点目で書いた'Proxy'オプションを指定した場合はそのsocketを使っていますが、未指定の場合は'TCPSocket'クラスが動くようです。Timeout.timeout内にてソケットをオープンして通信開始。
あと'Output_log'オプションにファイルを指定することで通信内容のログを保存できるそうです。使ったことないけど便利そう。
    
続いてcmdメソッド

```ruby:telnet.rb
# 引数optionsがハッシュだった場合は各種オプションを読み込む
def cmd(options)
    #...略...
    if options.kind_of?(Hash)
        string   = options["String"]
        match    = options["Match"]   if options.has_key?("Match")
        time_out = options["Timeout"] if options.has_key?("Timeout")
        fail_eof = options["FailEOF"] if options.has_key?("FailEOF")
    else
        string = options
    end

    # クラス内のputsメソッドに文字列を渡す
    self.puts(string)

    if block_given?
        waitfor({"Prompt" => match, "Timeout" => time_out, "FailEOF" => fail_eof}){|c| yield c }
    else
        waitfor({"Prompt" => match, "Timeout" => time_out, "FailEOF" => fail_eof})
    end
end
```
良いところの2点目で書いたcmdメソッドですが、オプションにハッシュを渡すと便利にやってくれます．

```ruby
sock = Net::Telnet.new('hostname')
sock.cmd({'String' => 'show run', 'Match' => /^hostname#$/})
```
上記のような形で引数を与えることで、期待するプロンプトの表示を待ち合わせる。
またcmdメソッドは実際にはputsメソッド（さらにその先にはprint, writeメソッドと連続して呼ぶ）を呼ぶことでsocketに対して出力をし、waitforメソッドで'Match'で指定した文字列なり正規表現を'Prompt'として待ち合わせてるようです。その他オプションについては先にも挙げましたが[リファレンス](http://docs.ruby-lang.org/ja/2.2.0/class/Net=3a=3aTelnet.html)参照のこと。ちなみに古典的なexpect風に使いたいときはputsとwaitforを織り交ぜながら書く感じでしょうか。

あとwaitforメソッドを書きたかったのですが、余力がなかったためまた今度にします。

# Net::Telnetを使ってみる
初歩的な実装として、今回はrancidのcloginを使用してGNS3上で動かしたCSR 1000Vに自動的にconfigを投入してみました。

## 準備
### ソフトウェア情報
* ruby ver2.1.6
* rancid ver3.2
* GNS3 ver1.3.11
* CSR1000V なんでもいいかも

### 環境
下図のような構成です。Macを使っているのですが、環境作るのにも結構はまったので、メモがてらいずれ記事にできればと思います。本当はSW1なしで直接接続したいのですが。現時点でどうにもならなかったので恥を捨てて放置。
![スクリーンショット 2015-12-05 1.25.59.png](https://qiita-image-store.s3.amazonaws.com/0/88191/1283753e-ac24-73f6-e58b-c16cc501b0f6.png)

ホスト'csr1000v'にパスワードやtelnet許可など最低限のものを設定し、予めcloginで自動ログインできるようにしています。ちなみにcloginを使わずにtelnetとかSSHを使う方法もあるかと思います。clogin使えば改行とかよしなにやってくれると思ってたんですが、なかなかうまくいかないものですね..(後半参照)

```bash
$ clogin csr1000v
csr1000v
spawn telnet csr1000v
Trying 10.0.0.1...
Connected to csr1000v.
Escape character is '^]'.


User Access Verification

Password: Kerberos: No default realm defined for Kerberos!

csr1000v>enable
Password:
csr1000v#
```
 
## 実装例その1

```ruby:net_telnet.rb
class Net::Telnet
    def c_cmd(options)
        if options.kind_of?(Hash)
            string   = options["String"]
            match    = options["Match"]   if options.has_key?("Match")
            time_out = options["Timeout"] if options.has_key?("Timeout")
            cmd({"String" => string.chomp, "Match" => match, "Timeout" => time_out})
        else
            cmd(options.chomp)
        end
    end
end
```

* Net::Telnetクラスを拡張し、c_cmdメソッドを追加
    * 改行が余計に入力されるようなので、cmdメソッドに与えるコマンドをchompする
* 今回は実装していませんが、良いところの3点目のロギングもこんな感じで作れそう

```ruby:nettelnet_test_1.rb
#!/bin/ruby

require 'bundler/setup'
require 'pry'
require 'net/telnet'

$: << File.expand_path('./lib', __FILE__)

require './net_telnet'

@host = 'csr1000v'
cmd   = "/usr/local/bin/clogin #{@host}"
prompt      = /^#{@host}(>|.*#)/
prompt_e    = /^#{@host}#/
prompt_conf = /^#{@host}(config)#/
prompt_if   = /^#{@host}(config-if)#/
timeout     = 10
wait_time   = 0

@c = Net::Telnet.new(
    { 'Prompt'     => prompt,
      'Timeout'    => timeout,
      'Waittime'   => wait_time,
      'Binmode'    => false,
      'Telnetmode' => false,
      'Proxy'      => IO.popen(cmd, 'r+')})

binding.pry
puts @c.waitfor(prompt_e)
puts @c.c_cmd('terminal length 0')
puts @c.c_cmd('conf t')
puts @c.c_cmd('no ip domain lookup')
puts @c.c_cmd('interface gigabitEthernet 1')
puts @c.c_cmd('description ManagementIF')
puts @c.c_cmd('no shutdown')
puts @c.c_cmd('end')
puts @c.c_cmd('exit')
```

* コマンドの実行結果はひとまずputsで標準出力に出す
    * やはりsyslog等へのロギングは必要ですね
* new後のwaitforはrancid処理部分のバッファを出力するための待ち合わせ

```ruby
[1] pry(main)> @c.waitfor(prompt_e)
=> "csr1000v\nspawn telnet csr1000v\nTrying 10.0.0.1...\nConnected to csr1000v.\nEscape character is '^]'.\n\n\nUser Access Verification\n\nPassword: Kerberos: No default realm defined for Kerberos!\n\ncsr1000v>enable\nPassword: \ncsr1000v#\ncsr1000v#\e]1;csr1000v\a\e]2;csr1000v\a"
```

* ただただひたすら機器に対してconfigを入れ続ける

実行

```text:実行結果
$ ruby nettelnet_test_1.rb
csr1000v
spawn telnet csr1000v
Trying 10.0.0.1...
Connected to csr1000v.
Escape character is '^]'.

User Access Verification

Password: Kerberos: No default realm defined for Kerberos!

csr1000v>enable
Password:
csr1000v#

csr1000v#
terminal length 0
csr1000v#

csr1000v#
conf t
Enter configuration commands, one per line.  End with CNTL/Z.
csr1000v(config)#

csr1000v(config)#no ip domain look
up
csr1000v(config)#
csr1000v(config)#
interface gigabitEthernet 1
csr1000v(config-if)#
csr1000v(config-if)#
description ManagementIF
csr1000v(config-if)#
```

* ありのままの姿です...改行がとんでもないことになっています
    * cmdでコマンドを出力すると基本的に空改行が1,2回多く入って結果が返ってきます。謎です
    * 出力されてない行ある...
    * 結局制御しきれなかったので、あきらめて本稿では放置してます
* 所詮は確認用の標準出力なのでどうでもいいと言えばそれまでですが、一応期待した出力結果は下記

```text:欲しかった実行結果
csr1000v# terminal length 0
csr1000v# conf t
Enter configuration commands, one per line.  End with CNTL/Z.
csr1000v(config)# no ip domain lookup
csr1000v(config)# interface gigabitEthernet 1
csr1000v(config-if)# description ManagementIF
csr1000v(config-if)# no shutdown
csr1000v(config-if)# end
csr1000v# exit
```


## 考察
実装例その1の問題点について、**config投入時の安全性**に着目して考えてみました。

* 誤った/過不足のあるコマンドに対応できない
    * 間違ったコマンドを投入しても終了せず、次のコマンドが続けて投入される    
    → STPの設定をしていないのにno shutしてしまう

* モードの階層変更の誤りに対応できない
    * CiscoやBrocadeのようにモードの階層が存在するCLIの場合、誤った階層で誤ったコマンドを投入する恐れがある     
    → interface config modeで入力すべきコマンドをglobal config modeで入力して死亡

* エラーハンドルが全くされていない
    * 異常終了した場合にもtelnetセッションが残る可能性がある     
    → telnetセッションが埋まってログインできなくなる

* その他
    * たくさんあると思います。教えてください。
        * showコマンドで事前事後の確認してないとか

## 実装例その2
考察を踏まえての実装例その2です。
**改行問題の闇が拭いきれなかったので下記コードは完全には動かないです。雰囲気とやりたいことを察していただけると幸いです。**

```ruby:nettelnet_test_2.rb
$ cat nettelnet_test_2.rb
#!/bin/ruby

require 'bundler/setup'
require 'pry'
require 'net/telnet'

$: << File.expand_path('./lib', __FILE__)

require './net_telnet'

@c
@host = 'csr1000v'
cmd   = "/usr/local/bin/clogin #{@host}"
prompt      = /^#{@host}(>|.*#)/
prompt_e    = /^#{@host}#/
prompt_conf = /^#{@host}\(config\)#/
prompt_if   = /^#{@host}\(config-if\)#/
timeout     = 10
wait_time   = 0

@err = [
    "Ambiguous command:",
    "Incomplete command.",
    "Invalid input detected at '^' marker."]

def exec_cmd(config_line, pre_prompt, post_prompt)
    @c.c_cmd({'String' => "\r", 'Match' => pre_prompt})
    puts result = @c.c_cmd({'String' => config_line, 'Match' => post_prompt})

    result.each_line do |line|
        @err.each do |err_line|
            return nil if line.include?(err_line)
        end
    end
    result
end

begin
    @c = Net::Telnet.new(
        { 'Prompt'     => prompt,
          'Timeout'    => timeout,
          'Waittime'   => wait_time,
          'Binmode'    => false,
          'Telnetmode' => false,
          'Proxy'      => IO.popen(cmd, 'r+')})

    @c.waitfor(prompt_e)
    raise unless exec_cmd("terminal length 0", prompt_e, prompt_e)
    raise unless exec_cmd("conf t", prompt_e, prompt_conf)
    raise unless exec_cmd("no ip domain lookup", prompt_conf, prompt_conf)
    raise unless exec_cmd("interface gigabitEthernet 1", prompt_conf, prompt_if)
    raise unless exec_cmd("description ManagementIF", prompt_if, prompt_if)
    raise unless exec_cmd("no shutdown", prompt_if, prompt_if)
    raise unless exec_cmd("end", prompt_if, prompt_e)

rescue => exc
    puts exc
rescue Net::ReadTimeout
    puts 'Error: Read Timeout'
ensure
    puts @c.c_cmd("exit")
end
```

### exec_cmdメソッドを作成
* プロンプト確認
    * パラメータ
        * config_line : 投入するコンフィグ一行
        * pre_prompt : config_lineのコマンドを投入する際のモードのプロンプト
        * post_prompt : config_lineのコマンドを投入した後のモードのプロンプト。pre_promptと同一の場合もある。    
    * 誤ったモードでのconfig投入が防止できる
        * ただしCiscoはIFモードの各IFの区別がないのでどのIFに入っているのか分からないからきつい...
    * pre_promptのチェックは空エンター打たないといけないけど、ほかに良いやり方ないかな
    * 例外処理のところとも関わるが期待したモードにいない時には'Waittime'だけ待ったのちNet::ReadTimeoutで拾われる
    * 入力結果をこの中でputsしてるのが大変イケてないと思います

* エラーチェック
    * Ciscoで出るエラーを定義し、cmdの戻り値に含まれていないか確認
        * エラーが含まれている場合には例外投げ
    * 全部定義するの大変そう。本当に全部網羅してるの？

### 例外処理を追加
* 前述の通りcmdで'Match'を指定した際などに期待した文字列が現れなかった場合にはNet::ReadTimeoutで回収される
* ensureで必ずexitしてホストから抜けるようにしておけばセッション埋め問題も解決できると思う

## 実行
たとえば下記のように間違ったIF名をいれた場合

```ruby:nettelnet_test_2.rb
raise unless exec_cmd("interface GINGAbitEthernet 1", prompt_conf, prompt_if)
```

```text:望ましい実行結果
$ ruby nettelnet_test_2.rb
csr1000v# terminal length 0
csr1000v#conf t
Enter configuration commands, one per line.  End with CNTL/Z.
csr1000v(config)# no ip domain lookup
csr1000v(config)# interface GINGAbitEthernet 1
Connection closed by foreign host.
```

# おわりに
Net::Telnetは私のような初心者にも比較的とっつきやすい自動化手法かもしれません。
いろいろ嵌って解決できなかった部分やコード上の問題などは今後の課題です。
（ちなみに他のベンダーの機器に使ってみた際には改行問題は起きなかったんです）
駄目出しや改善点など、ぜひぜひご教授ください。よろしくお願いします。ありがとうございました。
\# NetOpsCoding Advent Calendar 2015参加者の方々の記事楽しみにしてます。