こんにちは、ゆやりんです。今年のNetOpsCoding Advent Calendarもいよいよ最終日になりました。たくさんの役に立つ&頭のおかしい投稿ありがとうございます。お仕事が忙しかったので最終日を選びました。オオトリにふさわしい記事はなんだろうかと考えていましたが、やっぱりTelnetについて書こうと思います!!
はじめに
ネットワーク機器を操作する手段としてNETCONFやREST APIなどが登場する中、ネットワークの要件や制約からそういった機能を使うことができず、未だにTelnetやSSHでコンフィグを行わないといけない場合ということがあります。え?どの機器かって?昨日のtaijijijiくんの記事を読んでみて下さい。で、そういった状況でどのようにこのTelnetを利用したクライアントを実装するとよいのか、私のRubyでの実装の最善策(Best Current Implementational Practices)を紹介したいと思います。
大変申し訳無いことにコードを全部公開できないのですが、なるべくエッセンシャルな部分は共有できればと思います。いずれプロジェクトの全貌も公開できればなぁあと思います。
アーキテクチャ
Telnetクライアント周りの全体像はこんな感じなります。
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::EPIPE
や Net::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 IOS の show 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 }
でパーサの機能だけを持った試験用のクラスを作成します。
context
と let(:version) { '12.0' }
を使ってOSのバージョンを指定し、describe
の let(: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!!