From e44f8a9ac7330e9d15f95d1b18bc792320e7e714 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Sun, 11 Mar 2018 03:14:17 -0500 Subject: [PATCH 1/6] Add transport for Cisco IOS Signed-off-by: Jerry Aldrich --- lib/train/transports/cisco_ios.rb | 135 ++++++++++++++++++++++++++++++ test/unit/transports/cisco_ios.rb | 51 +++++++++++ 2 files changed, 186 insertions(+) create mode 100644 lib/train/transports/cisco_ios.rb create mode 100644 test/unit/transports/cisco_ios.rb diff --git a/lib/train/transports/cisco_ios.rb b/lib/train/transports/cisco_ios.rb new file mode 100644 index 00000000..588c48fe --- /dev/null +++ b/lib/train/transports/cisco_ios.rb @@ -0,0 +1,135 @@ +# encoding: utf-8 + +require 'train/plugins' +require 'train/transports/ssh' + +module Train::Transports + class BadEnablePassword < Train::TransportError; end + + class CiscoIOS < SSH + name 'cisco_ios' + + option :host, required: true + option :user, required: true + option :port, default: 22, required: true + + option :password, required: true + + # Used to elevate to enable mode (similar to `sudo su` in Linux) + option :enable_password + + def connection + validate_options(@options) + @connection ||= Connection.new(@options) + end + + def validate_options(options) + super(options) + end + + class Connection < BaseConnection + def initialize(options) + super(options) + + @host = @options.delete(:host) + @user = @options.delete(:user) + @port = @options.delete(:port) + + @enable_password = @options.delete(:enable_password) + + @prompt = /^\S+[>#]\r\n.*$/ + end + + def uri + "ssh://#{@user}@#{@host}:#{@port}" + end + + private + + def establish_connection + logger.debug("[SSH] opening connection to #{self}") + + @ssh = Net::SSH.start( + @host, + @user, + @options.delete_if { |_key, value| value.nil? }, + ) + + @channel ||= open_channel + + # Escalate privilege to enable mode if password is given + if @enable_password + run_command_via_channel("enable\r\n#{@enable_password}") + end + + # Prevent `--MORE--` by removing terminal length limit + run_command_via_channel('terminal length 0') + + @ssh + end + + def run_command_via_connection(cmd) + @session ||= establish_connection + + result = run_command_via_channel(cmd) + CommandResult.new(*format_result(result)) + end + + def format_result(result) + # IOS commands do not have an exit code, so we must capture known errors + case result + when /Invalid input detected/ + ['', result, 1] + when /Bad IP address or host name/ + ['', result, 1] + else + [result, '', 0] + end + end + + def run_command_via_channel(cmd) + # Ensure buffer is empty before sending data + @buf = '' + + logger.debug("[SSH] Running `#{cmd}` on #{self}") + @channel.send_data(cmd + "\r\n") + + logger.debug('[SSH] waiting for prompt') + until @buf =~ @prompt + raise BadEnablePassword if @buf =~ /Bad secrets/ + @channel.connection.process(0) + end + + # Save the buffer and clear it for the next command + output = @buf.dup + @buf = '' + + # Remove leading prompt + output.sub!(/(\r\n|^)\S+[>#]/, '') + + # Remove command string + output.sub!(/#{cmd}\r\n/, '') + + # Remove trailing prompt + output.gsub!(/\S+[>#](\r\n|$)/, '') + + output + end + + # Create an SSH channel that writes to @buf when data is received + def open_channel + logger.debug("[SSH] opening SSH channel to #{self}") + @ssh.open_channel do |ch| + ch.on_data do |_, data| + @buf += data + end + + ch.send_channel_request('shell') do |_, success| + raise 'Failed to open SSH shell' unless success + logger.debug('[SSH] shell opened') + end + end + end + end + end +end diff --git a/test/unit/transports/cisco_ios.rb b/test/unit/transports/cisco_ios.rb new file mode 100644 index 00000000..e97f1f19 --- /dev/null +++ b/test/unit/transports/cisco_ios.rb @@ -0,0 +1,51 @@ +# encoding: utf-8 + +require 'helper' +require 'train/transports/cisco_ios' + +describe 'Train::Transports::CiscoIOS' do + let(:cls) do + plat = Train::Platforms.name('mock').in_family('cisco_ios') + plat.add_platform_methods + Train::Platforms::Detect.stubs(:scan).returns(plat) + Train::Transports::CiscoIOS + end + + let(:options) do + { + host: 'fakehost', + user: 'fakeuser', + password: 'fakepassword', + } + end + + let(:cisco_ios) do + cls.new(options) + end + + describe 'CiscoIOS::Connection' do + let(:connection) { cls.new(options).connection } + + it 'raises an error when user is missing' do + options.delete(:user) + err = proc { cls.new(options).connection }.must_raise(Train::ClientError) + err.message.must_match(/must provide.*user/) + end + + it 'raises an error when host is missing' do + options.delete(:host) + err = proc { cls.new(options).connection }.must_raise(Train::ClientError) + err.message.must_match(/must provide.*host/) + end + + it 'raises an error when password is missing' do + options.delete(:password) + err = proc { cls.new(options).connection }.must_raise(Train::ClientError) + err.message.must_match(/must provide.*password/) + end + + it 'provides a uri' do + connection.uri.must_equal 'ssh://fakeuser@fakehost:22' + end + end +end From 4d9181cdb34f236adb1a5cde800d53d518ee6dc7 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Fri, 23 Mar 2018 10:54:14 -0700 Subject: [PATCH 2/6] Add more unit tests and minor style changes Signed-off-by: Jerry Aldrich --- lib/train/transports/cisco_ios.rb | 28 +++++++---- test/unit/transports/cisco_ios.rb | 77 +++++++++++++++++++++++-------- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/lib/train/transports/cisco_ios.rb b/lib/train/transports/cisco_ios.rb index 588c48fe..07cf3fbe 100644 --- a/lib/train/transports/cisco_ios.rb +++ b/lib/train/transports/cisco_ios.rb @@ -23,10 +23,6 @@ def connection @connection ||= Connection.new(@options) end - def validate_options(options) - super(options) - end - class Connection < BaseConnection def initialize(options) super(options) @@ -76,14 +72,21 @@ def run_command_via_connection(cmd) end def format_result(result) + stderr_with_exit_1 = ['', result, 1] + stdout_with_exit_0 = [result, '', 0] + # IOS commands do not have an exit code, so we must capture known errors case result + when /Bad IP address/ + stderr_with_exit_1 + when /Incomplete command/ + stderr_with_exit_1 when /Invalid input detected/ - ['', result, 1] - when /Bad IP address or host name/ - ['', result, 1] + stderr_with_exit_1 + when /Unrecognized host/ + stderr_with_exit_1 else - [result, '', 0] + stdout_with_exit_0 end end @@ -104,6 +107,12 @@ def run_command_via_channel(cmd) output = @buf.dup @buf = '' + format_output(output, cmd) + end + + # The buffer (@buf) contains all data sent/received on the SSH channel so + # we need to format the data to match what we would expect from Train + def format_output(output, cmd) # Remove leading prompt output.sub!(/(\r\n|^)\S+[>#]/, '') @@ -113,6 +122,9 @@ def run_command_via_channel(cmd) # Remove trailing prompt output.gsub!(/\S+[>#](\r\n|$)/, '') + # Remove trailing returns/newlines + output.gsub!(/(\r\n)+$/, '') + output end diff --git a/test/unit/transports/cisco_ios.rb b/test/unit/transports/cisco_ios.rb index e97f1f19..ed9bcaf7 100644 --- a/test/unit/transports/cisco_ios.rb +++ b/test/unit/transports/cisco_ios.rb @@ -11,7 +11,7 @@ Train::Transports::CiscoIOS end - let(:options) do + let(:opts) do { host: 'fakehost', user: 'fakeuser', @@ -20,32 +20,73 @@ end let(:cisco_ios) do - cls.new(options) + cls.new(opts) end describe 'CiscoIOS::Connection' do - let(:connection) { cls.new(options).connection } + let(:connection) { cls.new(opts).connection } - it 'raises an error when user is missing' do - options.delete(:user) - err = proc { cls.new(options).connection }.must_raise(Train::ClientError) - err.message.must_match(/must provide.*user/) - end + describe '#initialize' do + it 'raises an error when user is missing' do + opts.delete(:user) + err = proc { cls.new(opts).connection }.must_raise(Train::ClientError) + err.message.must_match(/must provide.*user/) + end + + it 'raises an error when host is missing' do + opts.delete(:host) + err = proc { cls.new(opts).connection }.must_raise(Train::ClientError) + err.message.must_match(/must provide.*host/) + end - it 'raises an error when host is missing' do - options.delete(:host) - err = proc { cls.new(options).connection }.must_raise(Train::ClientError) - err.message.must_match(/must provide.*host/) + it 'raises an error when password is missing' do + opts.delete(:password) + err = proc { cls.new(opts).connection }.must_raise(Train::ClientError) + err.message.must_match(/must provide.*password/) + end + + it 'provides a uri' do + connection.uri.must_equal 'ssh://fakeuser@fakehost:22' + end end - it 'raises an error when password is missing' do - options.delete(:password) - err = proc { cls.new(options).connection }.must_raise(Train::ClientError) - err.message.must_match(/must provide.*password/) + describe '#format_result' do + it 'returns correctly when result is `good`' do + connection.send(:format_result, 'good').must_equal ['good', '', 0] + end + + it 'returns correctly when result matches /Bad IP address/' do + output = "Translating \"nope\"\r\n\r\nTranslating \"nope\"\r\n\r\n% Bad IP address or host name\r\n% Unknown command or computer name, or unable to find computer address\r\n" + result = connection.send(:format_result, output) + result.must_equal ['', output, 1] + end + + it 'returns correctly when result matches /Incomplete command/' do + output = "% Incomplete command.\r\n\r\n" + result = connection.send(:format_result, output) + result.must_equal ['', output, 1] + end + + it 'returns correctly when result matches /Invalid input detected/' do + output = " ^\r\n% Invalid input detected at '^' marker.\r\n\r\n" + result = connection.send(:format_result, output) + result.must_equal ['', output, 1] + end + + it 'returns correctly when result matches /Unrecognized host/' do + output = "Translating \"nope\"\r\n% Unrecognized host or address, or protocol not running.\r\n\r\n" + result = connection.send(:format_result, output) + result.must_equal ['', output, 1] + end end - it 'provides a uri' do - connection.uri.must_equal 'ssh://fakeuser@fakehost:22' + describe '#format_output' do + it 'returns output containing only the output of the command executed' do + cmd = 'show calendar' + output = "show calendar\r\n10:35:50 UTC Fri Mar 23 2018\r\n7200_ios_12#\r\n7200_ios_12#" + result = connection.send(:format_output, output, cmd) + result.must_equal '10:35:50 UTC Fri Mar 23 2018' + end end end end From b2f03323efefdd4494be84391f26dcac16159e6a Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Mon, 26 Mar 2018 13:55:18 -0700 Subject: [PATCH 3/6] Clean up code after chatting with @TrevorBramble Signed-off-by: Jerry Aldrich --- lib/train/transports/cisco_ios.rb | 93 +++++++++++++++---------------- 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/lib/train/transports/cisco_ios.rb b/lib/train/transports/cisco_ios.rb index 07cf3fbe..f705ca49 100644 --- a/lib/train/transports/cisco_ios.rb +++ b/lib/train/transports/cisco_ios.rb @@ -27,10 +27,13 @@ class Connection < BaseConnection def initialize(options) super(options) + @session = nil + @buf = nil + + # Delete options to avoid passing them in to `Net::SSH.start` later @host = @options.delete(:host) @user = @options.delete(:user) @port = @options.delete(:port) - @enable_password = @options.delete(:enable_password) @prompt = /^\S+[>#]\r\n.*$/ @@ -45,93 +48,85 @@ def uri def establish_connection logger.debug("[SSH] opening connection to #{self}") - @ssh = Net::SSH.start( + Net::SSH.start( @host, @user, - @options.delete_if { |_key, value| value.nil? }, + @options.reject { |_key, value| value.nil? }, ) + end + + def session + return @session unless @session.nil? - @channel ||= open_channel + @session = open_channel(establish_connection) # Escalate privilege to enable mode if password is given if @enable_password - run_command_via_channel("enable\r\n#{@enable_password}") + run_command_via_connection("enable\r\n#{@enable_password}") end # Prevent `--MORE--` by removing terminal length limit - run_command_via_channel('terminal length 0') + run_command_via_connection('terminal length 0') - @ssh + @session end def run_command_via_connection(cmd) - @session ||= establish_connection - - result = run_command_via_channel(cmd) - CommandResult.new(*format_result(result)) - end - - def format_result(result) - stderr_with_exit_1 = ['', result, 1] - stdout_with_exit_0 = [result, '', 0] - - # IOS commands do not have an exit code, so we must capture known errors - case result - when /Bad IP address/ - stderr_with_exit_1 - when /Incomplete command/ - stderr_with_exit_1 - when /Invalid input detected/ - stderr_with_exit_1 - when /Unrecognized host/ - stderr_with_exit_1 - else - stdout_with_exit_0 - end - end - - def run_command_via_channel(cmd) # Ensure buffer is empty before sending data @buf = '' logger.debug("[SSH] Running `#{cmd}` on #{self}") - @channel.send_data(cmd + "\r\n") + session.send_data(cmd + "\r\n") logger.debug('[SSH] waiting for prompt') until @buf =~ @prompt raise BadEnablePassword if @buf =~ /Bad secrets/ - @channel.connection.process(0) + session.connection.process(0) end # Save the buffer and clear it for the next command output = @buf.dup @buf = '' - format_output(output, cmd) + result = format_output(output, cmd) + CommandResult.new(*format_result(result)) end - # The buffer (@buf) contains all data sent/received on the SSH channel so - # we need to format the data to match what we would expect from Train - def format_output(output, cmd) - # Remove leading prompt - output.sub!(/(\r\n|^)\S+[>#]/, '') + ERROR_MATCHERS = [ + 'Bad IP address', + 'Incomplete command', + 'Invalid input detected', + 'Unrecognized host', + ].freeze - # Remove command string - output.sub!(/#{cmd}\r\n/, '') + def format_result(result) + stderr_with_exit_1 = ['', result, 1] + stdout_with_exit_0 = [result, '', 0] - # Remove trailing prompt - output.gsub!(/\S+[>#](\r\n|$)/, '') + # IOS commands do not have an exit code, so we must capture known errors + match = ->(e) { result.include?(e) } + ERROR_MATCHERS.any?(&match) ? stderr_with_exit_1 : stdout_with_exit_0 + end - # Remove trailing returns/newlines - output.gsub!(/(\r\n)+$/, '') + # The buffer (@buf) contains all data sent/received on the SSH channel so + # we need to format the data to match what we would expect from Train + def format_output(output, cmd) + leading_prompt = /(\r\n|^)\S+[>#]/ + command_string = /#{cmd}\r\n/ + trailing_prompt = /\S+[>#](\r\n|$)/ + trailing_line_endings = /(\r\n)+$/ output + .sub(leading_prompt, '') + .sub(command_string, '') + .gsub(trailing_prompt, '') + .gsub(trailing_line_endings, '') end # Create an SSH channel that writes to @buf when data is received - def open_channel + def open_channel(ssh) logger.debug("[SSH] opening SSH channel to #{self}") - @ssh.open_channel do |ch| + ssh.open_channel do |ch| ch.on_data do |_, data| @buf += data end From c411e9e6c64db9f48c7c1b7cfa6d207f8a15e7fa Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Mon, 26 Mar 2018 14:11:48 -0700 Subject: [PATCH 4/6] Modify `#connection` to only validate options once Signed-off-by: Jerry Aldrich --- lib/train/transports/cisco_ios.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/train/transports/cisco_ios.rb b/lib/train/transports/cisco_ios.rb index f705ca49..7fc337ed 100644 --- a/lib/train/transports/cisco_ios.rb +++ b/lib/train/transports/cisco_ios.rb @@ -19,8 +19,7 @@ class CiscoIOS < SSH option :enable_password def connection - validate_options(@options) - @connection ||= Connection.new(@options) + @connection ||= Connection.new(validate_options(@options).options) end class Connection < BaseConnection From 49974c741d3cbb147c368905f32159a79723a121 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Mon, 26 Mar 2018 16:09:55 -0700 Subject: [PATCH 5/6] Modify `#format_result` to return `CommandResult` Signed-off-by: Jerry Aldrich --- lib/train/transports/cisco_ios.rb | 18 ++++++++++-------- test/unit/transports/cisco_ios.rb | 20 +++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/train/transports/cisco_ios.rb b/lib/train/transports/cisco_ios.rb index 7fc337ed..79198084 100644 --- a/lib/train/transports/cisco_ios.rb +++ b/lib/train/transports/cisco_ios.rb @@ -87,8 +87,7 @@ def run_command_via_connection(cmd) output = @buf.dup @buf = '' - result = format_output(output, cmd) - CommandResult.new(*format_result(result)) + format_result(format_output(output, cmd)) end ERROR_MATCHERS = [ @@ -98,13 +97,16 @@ def run_command_via_connection(cmd) 'Unrecognized host', ].freeze + # IOS commands do not have an exit code so we must compare the command + # output with partial segments of known errors. Then, we return a + # `CommandResult` with arguments in the correct position based on the + # result. def format_result(result) - stderr_with_exit_1 = ['', result, 1] - stdout_with_exit_0 = [result, '', 0] - - # IOS commands do not have an exit code, so we must capture known errors - match = ->(e) { result.include?(e) } - ERROR_MATCHERS.any?(&match) ? stderr_with_exit_1 : stdout_with_exit_0 + if ERROR_MATCHERS.none? { |e| result.include?(e) } + CommandResult.new(result, '', 0) + else + CommandResult.new('', result, 1) + end end # The buffer (@buf) contains all data sent/received on the SSH channel so diff --git a/test/unit/transports/cisco_ios.rb b/test/unit/transports/cisco_ios.rb index ed9bcaf7..c56b0db1 100644 --- a/test/unit/transports/cisco_ios.rb +++ b/test/unit/transports/cisco_ios.rb @@ -52,31 +52,33 @@ describe '#format_result' do it 'returns correctly when result is `good`' do - connection.send(:format_result, 'good').must_equal ['good', '', 0] + output = 'good' + Train::Extras::CommandResult.expects(:new).with(output, '', 0) + connection.send(:format_result, 'good') end it 'returns correctly when result matches /Bad IP address/' do output = "Translating \"nope\"\r\n\r\nTranslating \"nope\"\r\n\r\n% Bad IP address or host name\r\n% Unknown command or computer name, or unable to find computer address\r\n" - result = connection.send(:format_result, output) - result.must_equal ['', output, 1] + Train::Extras::CommandResult.expects(:new).with('', output, 1) + connection.send(:format_result, output) end it 'returns correctly when result matches /Incomplete command/' do output = "% Incomplete command.\r\n\r\n" - result = connection.send(:format_result, output) - result.must_equal ['', output, 1] + Train::Extras::CommandResult.expects(:new).with('', output, 1) + connection.send(:format_result, output) end it 'returns correctly when result matches /Invalid input detected/' do output = " ^\r\n% Invalid input detected at '^' marker.\r\n\r\n" - result = connection.send(:format_result, output) - result.must_equal ['', output, 1] + Train::Extras::CommandResult.expects(:new).with('', output, 1) + connection.send(:format_result, output) end it 'returns correctly when result matches /Unrecognized host/' do output = "Translating \"nope\"\r\n% Unrecognized host or address, or protocol not running.\r\n\r\n" - result = connection.send(:format_result, output) - result.must_equal ['', output, 1] + Train::Extras::CommandResult.expects(:new).with('', output, 1) + connection.send(:format_result, output) end end From 25403141432aa8c6b090641cdcd400a32dcd8d97 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Mon, 26 Mar 2018 16:14:16 -0700 Subject: [PATCH 6/6] Remove useless `nil` assignments Signed-off-by: Jerry Aldrich --- lib/train/transports/cisco_ios.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/train/transports/cisco_ios.rb b/lib/train/transports/cisco_ios.rb index 79198084..c3b314c2 100644 --- a/lib/train/transports/cisco_ios.rb +++ b/lib/train/transports/cisco_ios.rb @@ -26,9 +26,6 @@ class Connection < BaseConnection def initialize(options) super(options) - @session = nil - @buf = nil - # Delete options to avoid passing them in to `Net::SSH.start` later @host = @options.delete(:host) @user = @options.delete(:user)