Telnetでネットワーク機器を操作するときのBCIP - Ruby編

f:id:yuyarin:20161224171645p:plain:w480

こんにちは、ゆやりんです。今年のNetOpsCoding Advent Calendarもいよいよ最終日になりました。たくさんの役に立つ&頭のおかしい投稿ありがとうございます。お仕事が忙しかったので最終日を選びました。オオトリにふさわしい記事はなんだろうかと考えていましたが、やっぱりTelnetについて書こうと思います!!

はじめに

ネットワーク機器を操作する手段としてNETCONFやREST APIなどが登場する中、ネットワークの要件や制約からそういった機能を使うことができず、未だにTelnetSSHでコンフィグを行わないといけない場合ということがあります。え?どの機器かって?昨日のtaijijijiくんの記事を読んでみて下さい。で、そういった状況でどのようにこのTelnetを利用したクライアントを実装するとよいのか、私のRubyでの実装の最善策(Best Current Implementational Practices)を紹介したいと思います。

大変申し訳無いことにコードを全部公開できないのですが、なるべくエッセンシャルな部分は共有できればと思います。いずれプロジェクトの全貌も公開できればなぁあと思います。

アーキテクチャ

Telnetクライアント周りの全体像はこんな感じなります。

f:id:yuyarin:20161224174349p:plain:w480

Telnetクライアントの実装としては王道のNet::Telnetを使って、プロキシとしてrancidのxloginを使うのがベストでした。

それをラップする機器ごとのTelnetクライアントを作り、コマンドを投げたら必要なレスポンスだけが返ってくるようにします。

その機能を使って、各機器のCLI相当の機能を提供するのが「機器固有レイヤーのClient」です。例えばインターフェイスにデスクリプションを設定するなどの具体的なメソッドを提供います。

その上位アプリケーションとして「ビジネスロジックレイヤーのClient」が存在します。これら2つの違いは、同じインターフェイスにデスクリプションを設定するというタスクについて、機器固有レイヤーのクライアントがルータのインターフェイス名を取るのに対し、ビジネスロジックレイヤーのClientはお客様の回線IDなどを引数に取ることです。ビジネスロジックレイヤーのClientでは与えられたIDから機器固有のIDに変換する役割を持ちます。また、複数の機器がある時に共通のインターフェイスを提供することでさらに上位のアプリケーションに対して抽象化された機能を提供します。例えばLAGを作るという機能は、CiscoではPort-Channelを作ることですし、Juniperではaeインターフェイスを作ることですし、Brocade(Ironware)ではLAGオブジェクトを作ることで、それぞれ手順が異なります。これらの差分を吸収します。

ここで作られたクライアントはビジネスロジックレイヤーのワークフローの中から呼び出されます。

Net::Telnet + xlogin

チームメイトの goto_infamouse 君が検証をしてくれているので、あわせてそちらの記事もお読み下さい。

Net::Telnet + xlogin の特徴

Telnet経由で機器を操作する方法として他にpty + IO#expectや、ruby_expectなども検討しましたが、実装と比較を行った結果Net::Telnet + xloginが一番扱いやすいと判断しました。

rancidに付属するxloginはexpectのスクリプト群で、Cisco IOS用のcloginやFoundary用のfloginなど、数十種類の機器に対するログインスクリプトが用意されています。一からexpectのスクリプトを書くよりは、これらを利用したほうが効率が良いです。

使っている機器はFoundaryやCiscoそのものではないので、スクリプトはfloginやcloginを改造してhogeloginを機器ごとに作って別リポジトリで管理しています。

xloginの良いところはコードにパスワードを書く必要が無いところです。実行者の ~/.cloginrc に書く必要があるので、パスワードを誤ってGitリポジトリに突っ込んでしまうみたいなミスも起きません。

Net::Telnet + xlogin の異常系の処理

商用ネットワーク環境で動かすシステムを作るときに一番重要になるのが異常系の処理です。想定される色々なエラーに対してNet::Telnetの動作を検証しました。たとえば、xloginスクリプトが無い、パーミッションが違う、異常終了する、ホスト名が引けない、機器へのIP接続性がない、ACLで接続拒否される、パスワードが間違っている、などなど、色々なパターンです。だいたいは Errno::EPIPENet::ReadTimeout で処理できますが、以下の3パターンは、正しく処理しないとルータのvtyを専有し続けるという点で、気をつけなければならないエラーでした。

間違ったenableパスワード

.cloginrc に記載されているパスワードが間違っていた場合、xloginのタイムアウト秒後に Net::ReadTimeout の例外が発生しますが、正しく処理しないとxloginの親プロセスがinitの子プロセスに移って、ルータのvtyを専有し続けます。 Net::ReadTimeout を補足した時は即座に xlogin のプロセスを kill する必要があります。

ネットワークの切断でtelnetセッションが切れる

途中でネットワーク機器への接続が切れる場合です。さきほどのケースと同様に Net::ReadTimeout が発生しますが、 xlogin を kill してもネットワーク接続性が無いため、ルータの vty は専有されたままになり、ルータ側の telnet-timeout を待つことになります。接続性が切れてもすぐに回復するような場合や、ACLやHWが原因でそのセッションだけ停止される場合に問題になります。今のところ対処方法はありません。

Matchで指定したプロンプトが来なかった

Net::Telnetではプロンプトのマッチングを行うことができます。期待したプロンプトが来なかった場合に「間違ったenableパスワード」の場合と同様の動作が発生します。

A::Telnet

A::Telnet の役割

Net::Telnetのラッパーを各機器ごとに作っています。ルータAのライブラリをA::Telnetとしましょう。このラッパーの主な目的は「コマンドを投入して、表示結果を得る」という機能を上位アプリケーションに提供することです。更に挙げれば次のような機能を提供しています

  • Net::Telnetの例外を適切に処理する
  • ログイン関連の機器固有のエラーを処理する
  • コマンドに対する出力だけを返す
  • コマンド実行後のプロンプトの判定を可能にする
  • 機器の操作ログの保存

A::Telnet のコマンド処理

例えば show clock というコマンドを実行すると時刻が表示されて、次のプロンプトが出る表示が出るような場合、Net::Telnetでcmdメソッドを実行すると次のような値が帰ってきます。

puts telnet.cmd('show clock')
=> "show clock\n22:02:20.947 JST Sat Dec 24 2016\n\e]1hostname\ahostname"

入力したコマンドそのものがエコーされ、実際に欲しい表示が出力され、その次のプロンプト(しかもターミナルのタイトルを変更するエスケープシーケンスが入っていることがある)が含まれる3行の文字列が返ってきます。これらを処理して本来欲しいコマンドの出力結果だけを上位アプリケーションに返します。

この時に次のプロンプトを得ることができるので、保存しておくとプロンプトの判定に利用することができます。Telnet経由の操作では正しい階層に入ったかどうかプロンプトで確認する必要があります。例えば global レベルと interface レベルの両方で実行可能なコマンドがあったときに、interfaceレベルでコマンドを実行しようとしたのに interface GigabitEthernet 1/1 が失敗して global レベルで投入してしまった、というようなことが起こりうるからです(そういうCLI設計もやめてほしいですが)。

あるネットワークに対する実装は次のようになっています。

    def execute_cmd(opt)
      result = @connection.cmd(opt) # @connectionはNet::Telnetのインスタンスです
      response = result.split($/)
      # 先頭行は入力したコマンドなので除外
      command = response.shift
      # 最終行はコマンド入力後のプロンプトなので結果から除外
      next_prompt = response.pop
      _log(response.join($/))
      @current_prompt = next_prompt
      return response.join($/)
    end

A::Telnet の接続開始部分

Net::Telnetでxloginを使った時のやっかいな部分は、デバッグが非常にやりづらいということです。何が原因でどこで死んでいるのかぱっと見分からないからです。さらにプロセスが残りる続けたりルータのvtyを食ったままになっていたりと大変でした。接続を開始するメソッドはこんな感じです。Proxy オプションに xlogin を IO.popen で作ったオブジェクトを渡してあげることで、ログイン処理をrancidに丸投げすることができます。プロセス終了時や異常終了時に xlogin を kill することに備えて、pidを保存しておきます。

    def open_connection
      proxy_command = "#{@xlogin} #{@hostname}"
      prompt = /^telnet@#{@hostname}(>|.*#)/
      prompt_enable = /^telnet@#{@hostname}#/
      timeout = @timeout
      wait_time = 0

      begin
        @proxy = IO.popen(proxy_command, 'r+')
        @proxy_pid = @proxy.pid
        _log("proxy opened #{proxy_command} with pid #{@proxy_pid}")
        @connection = Net::Telnet.new({
          'Prompt'     => prompt,
          'Timeout'    => timeout,
          'Waittime'   => wait_time,
          'Binmode'    => false,
          'Telnetmode' => false,
          'Proxy'      => @proxy,
        })
        _log("created Net::Telnet instance")

        r = execute_waitfor(prompt_enable)
        # 1. proxyコマンドが異常な挙動を起こしている場合
        unless r
          error_message = "unexpected behavior of proxy command"
          _log(error_message)
          raise Errno::EPIPE, error_message
        end
        # 1. Error: Couldn't login
        #   a. ホスト名が解決できない場合(Name or service not known)
        #   b. ネットワーク接続性はあるがホストに接続できない場合(No route to host)
        # 2. Error: Connection Refused
        #   a. telnetのACLでdenyされている(Connection refused)
        # 3. Error: Connection closed
        #   a. ログインパスワードが間違っている
        if r =~ /Error:/
          raise A::LoginError, r
        end
        # 2回waitforするとうまくいくというおまじない
        r = execute_waitfor(prompt_enable)

        _log("login completed")
        _log(@current_prompt)

        cmd("terminal length 0")

      # Errno::ENOENT
      #   proxyコマンドが存在しない場合
      rescue Errno::ENOENT => e
        raise e
      # Errno::EACCES
      #   proxyコマンドの実行権限がない場合
      rescue Errno::EACCES => e
        raise e
      # Errno::EPIPE
      #   proxyコマンドとのパイプが確立できない場合
      #   接続エラーなのでコネクションを閉じる必要が無い
      rescue Errno::EPIPE => e
        raise e
      # Net::ReadTimeout
      #   enableパスワードの不一致
      #   ネットワーク接続性の喪失
      # X::LoginError
      #   17行上で投げてる
      #
      # 接続した状態でのexpectのエラーなのでコネクションを閉じる
      rescue => e
        close_connection
        raise e
      end
   end

このメソッドはpriavteで、コンストラクタから呼ばれます。

「2回waitforするとうまくいくというおまじない」という部分に苦労を感じ取って頂けるとうれしいです。。。

A::Telnet のコマンド実行部分

publicメソッドとして外部に出しているのがこのcmdメソッドです。コマンドを受け取って、その表示結果だけを返す機能を提供しています。当初はここでプロンプト判定も行っていましたが、current_promptメソッドを提供して、判定は上位アプリケーションに任せる設計に変更しました。

    def cmd(command)
      result = ''
      begin
        @current_command = command
        _log("#{@current_prompt}#{command}")
        result = execute_cmd(command)
      rescue Errno::EPIPE => e
        raise e
      rescue => e
        close_connection
        raise e
      end

      return result
    end

A::Telnet の接続終了部分

接続終了時には SIGHUP で xlogin のプロセスを殺します。先にexitなどのコマンドでtelnetセッションから抜けている場合には xlogin のプロセスが終了しているためはkillを行おうとしても IOError が発生します。この例外はここで無視します。

    def close_connection
      return unless @connection
      _log("closing connection...")
      begin
        unless @proxy_pid==0
          _log("sending SIGHUP to proxy with pid #{@proxy_pid}...")
          # telnetなのでSIGHUPで適切に接続を終了してくれることを期待する
          Process.kill(:HUP, @proxy_pid)
        end
      # Errno::ESRCH
      #   プロセスが存在しない場合
      rescue Errno::ESRCH => e
        _log("process with pid #{@proxy_pid} not found")
        # NOP
      end
      begin
        @connection.close
      # IOError
      #   @connectionがcloseされている場合
      #   @proxyをkillしているので必ず発生するはず
      rescue IOError => e
        # NOP
      end
      _log("connection closed")
      @connection = nil
      @proxy_pid = 0
    end

いまや安全にこのクライアントを使って機器操作できているのはチームメイトの goto_infamouse 君がNet::Telnetの動作検証をしっかりやってくれたおかげです。

機器固有レイヤの A::Client

機器固有レイヤの A::Client の役割

このA::Clientクラスは機器固有の処理をする部分で一番実装が膨らむ箇所です。具体的にはCLIコマンドに相当するメソッドを提供して、その結果を構造化されたデータとして返します。

例えば Cisco IOSshow interface summary コマンドを例にすると、次のような利用を想定しています。

c = CiscoIOS::Client.new(hostname, options)
c.show_interface_summary
=> [{
  'Interface' => 'FastEthernet0',
  'Up' => true,
  'IHQ' => 0,
  ...
},{
  'Interface' => 'FastEthernet1',
  'Up' => false,
  'IHQ' => 0,
  ...
},
...
]

また、各種コマンドを実行したときのエラーハンドルやプロンプトのチェックもこのクラスで実施します。

機器固有レイヤの A::Client の構成

こんな感じの仕組みになっています。

module A
  class Client

    include ::A::CliParser

    def initialize(hostname, options = {})
      @hostname = hostname
      @telnet = start_client(hostname, options)
    end

    def close
      stop_client
    end

    private

    def start_client(hostname, options)
      A::Telnet.new(hostname, options)
    end

    def stop_client
      @telnet.close
    end

    def exec_cmd(command)
      @telnet.cmd(command)
    end

    public

    def show_interface(interface_names)
      interface_names = validate_interface_names(interface_names)
      res = exec_cmd("show interface #{interface_names}")
      parse_show_interface(res)
    end
  end

  module CliParser
    def parse_show_interface(res)
      # CLIの結果をパースして構造化して返す
    end
  end
end

CLIのパーサはテストをしやすくするためにモジュールとして切り出してあります。start_clientやexec_cmdなどもメソッドとして切り出しているのは、テストのときにモックのメソッドに差し替えられるようにするためです。

機器固有レイヤの A::Client のCLIパーサのテスト

このクラスでは愚直にCLIコマンドに相当するメソッドとそのパーサを実装するだけなので、それほど悩む点はありませんが、ひとつあるとするならばCLIパーサのテストです。

CLIの表示結果から、得られるHashオブジェクトが一意に定まるはずなので、テストケースとして入力をテキストで、出力をYAMLで用意しておきます。

# spec/fixtures/ios/15.0/show_interface_summary/mixed-interfaces.txt

 *: interface is up
 IHQ: pkts in input hold queue     IQD: pkts dropped from input queue
 OHQ: pkts in output hold queue    OQD: pkts dropped from output queue
 RXBS: rx rate (bits/sec)          RXPS: rx rate (pkts/sec)
 TXBS: tx rate (bits/sec)          TXPS: tx rate (pkts/sec)
 TRTL: throttle count

  Interface                   IHQ       IQD       OHQ       OQD      RXBS      RXPS      TXBS      TXPS      TRTL
-----------------------------------------------------------------------------------------------------------------
* FastEthernet0                 0         0         0       130    194000        50    195000        52         0
  FastEthernet1                 0         0         0         0         0         0         0         0         0
# spec/fixtures/ios/15.0/show_interface_summary/mixed-interfaces.yml
- Interface: "FastEthernet0"
  Up: true
  IHQ: 0
  ...
- Interface: "FastEthernet1"
  Up: false
  IHQ: 0
  ...

テストはRSpecを使って次のように書いています。

# spec/ios/cli_parser_spec.rb 
def path_of_example(version, command, case_name)
  command = command.tr(' ', '_')
  "spec/fixtures/ios/#{version}/#{command}/#{case_name}"
end

def load_input(version, command, case_name)
  File.read("#{path_of_example(version, command, case_name)}.txt")
end

def load_expected_output(version, command, case_name)
  YAML.load_file("#{path_of_example(version, command, case_name)}.yml")
end

def get_method_name(command)
  command = command.tr(' ', '_').tr('-', '_')
  "parse_#{command}"
end

def exec_parse(parser, version, command, case_name)
  input = load_input(version, command, case_name)
  method_name = get_method_name(command)
  parser.method(method_name).call(input)
end

RSpec.shared_examples "interface mixed-interfaces" do
  it "parses normal output" do
    case_name = 'mixed-interfaces'
    expected = load_expected_output(version, command, case_name)
    expect(exec_parse(parser, version, command, case_name)).to eq(expected)
  end
end

RSpec.shared_examples "interface error-invalid-slot-number" do
  it "raises error for invalid slot number" do
    case_number = 'error-invalid-slot-number'
    expect{ exec_parse(parser, version, command, case_number) }.to raise_error(A::InvalidInputError, 'Invalid input -> 100/1')
  end
end


RSpec.describe A::CliParser do

  let(:test_class) { Struct.new(:parser) { include A::CliParser } }
  let(:parser) { test_class.new }

  context "with IOS Version 12.0" do
    let(:version) { '12.0' }

    describe "parse show interface summary" do
      let(:command) { 'show interface summary' }
      include_examples "interface mixed-interfaces"
      include_examples "interface error-invalid-slot-number"
      ...
    end
    describe "parse show ip ospf" do
    ...
  end

  context "with IOS Version 15.0" do
    let(:version) { '15.0' }
    ...
  end

  ...
end

工夫したポイントとしては、バージョンが複数あり、コマンドが複数あり、ケースが複数あるので、それぞれに識別子をつけてそれを指定すればテストができるようにしたところです。ここでいうケースは「インターフェイス名に無効な値を指定した場合」とか「10Gの正しく設定されたインターフェイスの場合」とかです。

まず、CLIParserをモジュールとして分離しているので、let(:test_class) { Struct.new(:parser) { include A::CliParser } }let(:parser) { test_class.new } でパーサの機能だけを持った試験用のクラスを作成します。

contextlet(:version) { '12.0' } を使ってOSのバージョンを指定し、describelet(:command) { 'show interface summary' } で対象のコマンドを指定します。

ケースは RSpec.shared_examples を使ってバージョンやコマンドを通して共通化しておきます。これを include_examples case_name で呼び出します。

バージョン、コマンド、ケース名の3つがわかると、入力ファイルのパス、出力ファイルのパス、CLIパーサのメソッド名が自動的にわかるように命名規則を定めています。

shared_example の中では、入力のテキストを読み込み、パーサに食わせて、出力のYAMLと比較する、ということをやっています。

このようにテストを書いておけば、少なくとも既知のコマンド出力に対しての動作を保証できるのでとても安心です。

ビジネスロジックレイヤのClient

ビジネスロジックレイヤでは次のことを行うつもりです。というのもまだしっかりと実装ができていなくて、この設計でいいのかわからないからです。

たとえばVLANを変更するといった処理をするときに、機器Aではインターフェイスをshutdownする必要があるが、機器Bでは必要ない、といった場合に、これらのワークフローの違いをどう扱えばいいのかという課題に直面します。上位のワークフローで扱うのか、このクラスでワークフローを隠蔽したメソッドを提供するのか、難しいです。単にやるだけならこのレイヤーで吸収してしまえばいいのですが、例えば「Web画面にshutdownされることの警告を出したい」となってしまうと困ります(実際にはshutdownされないのに警告を出して「shutdownされる可能性があります」みたいに言う手もありますが)。

このあたりに関しては試行錯誤している最中なので、もう少し知見が溜まってきたら記事を書いてみたいですし、もしも経験のある方がいらっしゃれば記事を書いてほしいです。

さいごに

今年のクリスマスイブはTelnetの記事を書いて過ごしました。

Merry Christmas and Happy NetOpsCoding!!