From 0c89d2a8dc064c0cb907cf818191ad9a1b4e588e Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Sun, 1 Apr 2018 15:41:53 +0000 Subject: [PATCH] Move Cisco IOS connection under SSH transport Signed-off-by: Jerry Aldrich --- lib/train/transports/cisco_ios.rb | 140 ------------------- lib/train/transports/cisco_ios_connection.rb | 119 ++++++++++++++++ lib/train/transports/ssh.rb | 14 ++ test/unit/transports/cisco_ios.rb | 94 ------------- test/unit/transports/cisco_ios_connection.rb | 81 +++++++++++ test/unit/transports/ssh_test.rb | 8 +- 6 files changed, 218 insertions(+), 238 deletions(-) delete mode 100644 lib/train/transports/cisco_ios.rb create mode 100644 lib/train/transports/cisco_ios_connection.rb delete mode 100644 test/unit/transports/cisco_ios.rb create mode 100644 test/unit/transports/cisco_ios_connection.rb diff --git a/lib/train/transports/cisco_ios.rb b/lib/train/transports/cisco_ios.rb deleted file mode 100644 index c3b314c2..00000000 --- a/lib/train/transports/cisco_ios.rb +++ /dev/null @@ -1,140 +0,0 @@ -# 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 - @connection ||= Connection.new(validate_options(@options).options) - end - - class Connection < BaseConnection - def initialize(options) - super(options) - - # 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.*$/ - end - - def uri - "ssh://#{@user}@#{@host}:#{@port}" - end - - private - - def establish_connection - logger.debug("[SSH] opening connection to #{self}") - - Net::SSH.start( - @host, - @user, - @options.reject { |_key, value| value.nil? }, - ) - end - - def session - return @session unless @session.nil? - - @session = open_channel(establish_connection) - - # Escalate privilege to enable mode if password is given - if @enable_password - run_command_via_connection("enable\r\n#{@enable_password}") - end - - # Prevent `--MORE--` by removing terminal length limit - run_command_via_connection('terminal length 0') - - @session - end - - def run_command_via_connection(cmd) - # Ensure buffer is empty before sending data - @buf = '' - - logger.debug("[SSH] Running `#{cmd}` on #{self}") - session.send_data(cmd + "\r\n") - - logger.debug('[SSH] waiting for prompt') - until @buf =~ @prompt - raise BadEnablePassword if @buf =~ /Bad secrets/ - session.connection.process(0) - end - - # Save the buffer and clear it for the next command - output = @buf.dup - @buf = '' - - format_result(format_output(output, cmd)) - end - - ERROR_MATCHERS = [ - 'Bad IP address', - 'Incomplete command', - 'Invalid input detected', - '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) - 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 - # 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(ssh) - 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/lib/train/transports/cisco_ios_connection.rb b/lib/train/transports/cisco_ios_connection.rb new file mode 100644 index 00000000..7aaae62b --- /dev/null +++ b/lib/train/transports/cisco_ios_connection.rb @@ -0,0 +1,119 @@ +# encoding: utf-8 + +class Train::Transports::SSH + class CiscoIOSConnection < BaseConnection + class BadEnablePassword < Train::TransportError; end + + def initialize(options) + super(options) + + # Extract 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) + + # Use all options left that are not `nil` for `Net::SSH.start` later + @ssh_options = options.reject { |_key, value| value.nil? } + + @prompt = /^\S+[>#]\r\n.*$/ + end + + def uri + "ssh://#{@user}@#{@host}:#{@port}" + end + + private + + def establish_connection + logger.debug("[SSH] opening connection to #{self}") + + Net::SSH.start(@host, @user, @ssh_options) + end + + def session + return @session unless @session.nil? + + @session = open_channel(establish_connection) + + # Escalate privilege to enable mode if password is given + if @enable_password + run_command_via_connection("enable\r\n#{@enable_password}") + end + + # Prevent `--MORE--` by removing terminal length limit + run_command_via_connection('terminal length 0') + + @session + end + + def run_command_via_connection(cmd) + # Ensure buffer is empty before sending data + @buf = '' + + logger.debug("[SSH] Running `#{cmd}` on #{self}") + session.send_data(cmd + "\r\n") + + logger.debug('[SSH] waiting for prompt') + until @buf =~ @prompt + raise BadEnablePassword if @buf =~ /Bad secrets/ + session.connection.process(0) + end + + # Save the buffer and clear it for the next command + output = @buf.dup + @buf = '' + + format_result(format_output(output, cmd)) + end + + ERROR_MATCHERS = [ + 'Bad IP address', + 'Incomplete command', + 'Invalid input detected', + '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) + 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 + # 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 = /#{Regexp.quote(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(ssh) + 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 diff --git a/lib/train/transports/ssh.rb b/lib/train/transports/ssh.rb index e8f39219..1b0907fa 100644 --- a/lib/train/transports/ssh.rb +++ b/lib/train/transports/ssh.rb @@ -37,6 +37,7 @@ class SSH < Train.plugin(1) # rubocop:disable Metrics/ClassLength name 'ssh' require 'train/transports/ssh_connection' + require 'train/transports/cisco_ios_connection' # add options for submodules include_options Train::Extras::CommandWrapper @@ -193,6 +194,19 @@ def create_new_connection(options, &block) @connection_options = options conn = Connection.new(options, &block) + + # Cisco IOS requires a special implementation of `Net:SSH`. This uses the + # SSH transport to identify the platform, but then replaces SSHConnection + # with a CiscoIOSConnection in order to behave as expected for the user. + if defined?(conn.platform.cisco_ios?) && conn.platform.cisco_ios? + ios_options = {} + ios_options[:host] = @options[:host] + ios_options[:user] = @options[:user] + ios_options[:enable_password] = @options[:enable_password] + ios_options.merge!(@connection_options) + conn = CiscoIOSConnection.new(ios_options) + end + @connection = conn unless conn.nil? end diff --git a/test/unit/transports/cisco_ios.rb b/test/unit/transports/cisco_ios.rb deleted file mode 100644 index c56b0db1..00000000 --- a/test/unit/transports/cisco_ios.rb +++ /dev/null @@ -1,94 +0,0 @@ -# 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(:opts) do - { - host: 'fakehost', - user: 'fakeuser', - password: 'fakepassword', - } - end - - let(:cisco_ios) do - cls.new(opts) - end - - describe 'CiscoIOS::Connection' do - let(:connection) { cls.new(opts).connection } - - 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 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 - - describe '#format_result' do - it 'returns correctly when result is `good`' do - 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" - 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" - 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" - 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" - Train::Extras::CommandResult.expects(:new).with('', output, 1) - connection.send(:format_result, output) - end - end - - 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 diff --git a/test/unit/transports/cisco_ios_connection.rb b/test/unit/transports/cisco_ios_connection.rb new file mode 100644 index 00000000..8c270f25 --- /dev/null +++ b/test/unit/transports/cisco_ios_connection.rb @@ -0,0 +1,81 @@ +# encoding: utf-8 + +require 'helper' +require 'train/transports/ssh' + +describe 'CiscoIOSConnection' do + let(:cls) do + Train::Platforms::Detect::Specifications::OS.load + plat = Train::Platforms.name('mock').in_family('cisco_ios') + plat.add_platform_methods + plat.stubs(:cisco_ios?).returns(true) + Train::Platforms::Detect.stubs(:scan).returns(plat) + Train::Transports::SSH + end + + let(:opts) do + { + host: 'fakehost', + user: 'fakeuser', + password: 'fakepassword', + } + end + + let(:connection) do + cls.new(opts).connection + end + + describe '#initialize' do + it 'provides a uri' do + connection.uri.must_equal 'ssh://fakeuser@fakehost:22' + end + end + + describe '#format_result' do + it 'returns correctly when result is `good`' do + 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" + 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" + 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" + 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" + Train::Extras::CommandResult.expects(:new).with('', output, 1) + connection.send(:format_result, output) + end + end + + describe '#format_output' do + it 'returns the correct output' 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 + + it 'returns the correct output when a pipe is used' do + cmd = 'show running-config | section line con 0' + output = "show running-config | section line con 0\r\nline con 0\r\n exec-timeout 0 0\r\n privilege level 15\r\n logging synchronous\r\n stopbits 1\r\n7200_ios_12#\r\n7200_ios_12#" + result = connection.send(:format_output, output, cmd) + result.must_equal "line con 0\r\n exec-timeout 0 0\r\n privilege level 15\r\n logging synchronous\r\n stopbits 1" + end + end +end diff --git a/test/unit/transports/ssh_test.rb b/test/unit/transports/ssh_test.rb index 7d3bf865..47d71918 100644 --- a/test/unit/transports/ssh_test.rb +++ b/test/unit/transports/ssh_test.rb @@ -5,10 +5,10 @@ describe 'ssh transport' do let(:cls) do - plat = Train::Platforms.name('mock').in_family('linux') - plat.add_platform_methods - Train::Platforms::Detect.stubs(:scan).returns(plat) - Train::Transports::SSH + plat = Train::Platforms.name('mock').in_family('linux') + plat.add_platform_methods + Train::Platforms::Detect.stubs(:scan).returns(plat) + Train::Transports::SSH end let(:conf) {{ host: rand.to_s,