Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add transport for Cisco IOS #271

Merged
merged 6 commits into from
Mar 27, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions lib/train/transports/cisco_ios.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# 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)

@session = nil
@buf = nil

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Is there a reason for these? In Ruby, instance variables are nil by default, and attempting to access the value of one without having declared it already just returns nil. @session should only ever be assigned or accessed via session(), even. @buf may be shared by those two methods, but run_command_via_connection() clears it on each invocation anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, you make a solid argument. Killing those with fire.


# 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 = ''

result = format_output(output, cmd)
CommandResult.new(*format_result(result))
end

ERROR_MATCHERS = [
'Bad IP address',
'Incomplete command',
'Invalid input detected',
'Unrecognized host',
].freeze

def format_result(result)
stderr_with_exit_1 = ['', result, 1]
stdout_with_exit_0 = [result, '', 0]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put my finger on why these bother me. One of them is always a spurious assignment. At this point I'd definitely either extract them as methods (format_err(result), format_out(result)) or inline the arrays, or inline CommandResult.new().

def format_result(result)
  if ERROR_MATCHERS.none? { |e| result.include?(e) }
    CommandResult.new(result, '', 0)
  else
    CommandResult.new('', result, 1)
  end
end

I do wish there were CommandResult subclasses that revealed intent though. ErrorResult.new(result) which automatically assigns result to stderr and returns 1, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad you put a finger on it. I took your code suggestion and ran with it. Let me know if it's any better.


# 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

# 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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String#sub and String#gsub return the modified string, so you can chain them. Also, giving the regular expression matchers explaining variables can help avoid an almost certain fate of the regexp being changed, but not the comment that explains it.

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
92 changes: 92 additions & 0 deletions test/unit/transports/cisco_ios.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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
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

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