asa_console 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +15 -0
- data/.yardopts +8 -0
- data/LICENSE.md +22 -0
- data/README.md +116 -0
- data/asa_console.gemspec +30 -0
- data/bin/asatest +113 -0
- data/lib/asa_console.rb +283 -0
- data/lib/asa_console/config.rb +170 -0
- data/lib/asa_console/error.rb +43 -0
- data/lib/asa_console/terminal.rb +9 -0
- data/lib/asa_console/terminal/fake_ssh.rb +145 -0
- data/lib/asa_console/terminal/ssh.rb +223 -0
- data/lib/asa_console/test.rb +134 -0
- data/lib/asa_console/test/script.rb +48 -0
- data/lib/asa_console/util.rb +151 -0
- data/script/test_clock.rb +39 -0
- data/script/test_connect.rb +10 -0
- data/script/test_error_command.rb +15 -0
- data/script/test_error_connect.rb +31 -0
- data/script/test_error_enable.rb +18 -0
- data/script/test_names.rb +33 -0
- data/script/test_object.rb +50 -0
- data/script/test_terminal.rb +25 -0
- data/script/test_version.rb +31 -0
- metadata +209 -0
@@ -0,0 +1,134 @@
|
|
1
|
+
|
2
|
+
require 'asa_console'
|
3
|
+
require 'asa_console/test/script'
|
4
|
+
|
5
|
+
class ASAConsole
|
6
|
+
#
|
7
|
+
# Methods to declare and execute {ASAConsole} test scripts in a lab
|
8
|
+
# environment.
|
9
|
+
#
|
10
|
+
# @example Contents of `./my_test_file.rb`
|
11
|
+
# ASAConsole::Test.script do |asa|
|
12
|
+
# log 'Connecting...'
|
13
|
+
# asa.connect
|
14
|
+
# log 'Executing the "show failover" command...'
|
15
|
+
# asa.show('failover')
|
16
|
+
# log 'Disconnecting...'
|
17
|
+
# asa.disconnect
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# @example Execute scripts in `./my_test_file.rb`
|
21
|
+
# require 'asa_console/test'
|
22
|
+
#
|
23
|
+
# terminal_opts = { host: '192.0.2.1', user: 'admin', password: 'secret' }
|
24
|
+
#
|
25
|
+
# ASAConsole::Test.color_scheme = :light
|
26
|
+
# ASAConsole::Test.start('./my_test_file.rb', terminal_opts)
|
27
|
+
#
|
28
|
+
module Test
|
29
|
+
#
|
30
|
+
# @api private
|
31
|
+
@@scripts = []
|
32
|
+
#
|
33
|
+
# @api private
|
34
|
+
@@colors = {}
|
35
|
+
|
36
|
+
# Declare a test script by passing a block to this method.
|
37
|
+
#
|
38
|
+
# @yieldparam asa [ASAConsole] object to manipulate
|
39
|
+
def self.script(&block)
|
40
|
+
klass = Class.new(Script)
|
41
|
+
klass.send(:define_method, 'test!', &block)
|
42
|
+
@@scripts << klass
|
43
|
+
end
|
44
|
+
|
45
|
+
# Set the color scheme for test script output.
|
46
|
+
#
|
47
|
+
# @param scheme_key [Symbol]
|
48
|
+
# `:light` for light (or bold) colors, `:dark` for dark colors. Any other
|
49
|
+
# option prevents colorized output.
|
50
|
+
def self.color_scheme=(scheme_key)
|
51
|
+
case scheme_key
|
52
|
+
when :light
|
53
|
+
@@colors = {
|
54
|
+
prompt: "\e[1;36m",
|
55
|
+
input: "\e[1;33m",
|
56
|
+
output: "\e[1;32m",
|
57
|
+
log: "\e[1;31m",
|
58
|
+
info: "\e[1;35m"
|
59
|
+
}
|
60
|
+
when :dark
|
61
|
+
@@colors = {
|
62
|
+
prompt: "\e[0;36m",
|
63
|
+
input: "\e[0;33m",
|
64
|
+
output: "\e[0;32m",
|
65
|
+
log: "\e[0;31m",
|
66
|
+
info: "\e[0;35m"
|
67
|
+
}
|
68
|
+
else
|
69
|
+
@@colors = {}
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# @api private
|
74
|
+
def self.colorize(str, color_key)
|
75
|
+
return str unless @@colors[color_key] && !str.empty?
|
76
|
+
@@colors[color_key] + str + "\e[0m"
|
77
|
+
end
|
78
|
+
|
79
|
+
# Returns the absolute path of the test script directory or, if a test name
|
80
|
+
# is given, returns the path to the test file.
|
81
|
+
#
|
82
|
+
# @overload test_path
|
83
|
+
# @return [String] the filesystem path of the test script directory
|
84
|
+
# @overload test_path(test_name)
|
85
|
+
# @param test_name [String] the test name
|
86
|
+
# @return [String] the filesystem path of the named test script
|
87
|
+
# @return [String] a filesystem path
|
88
|
+
def self.test_path(test_name = nil)
|
89
|
+
path = File.realpath(File.join(File.dirname(__FILE__), '..', '..', 'script'))
|
90
|
+
path = File.join(path, "test_#{test_name}.rb") if test_name
|
91
|
+
path
|
92
|
+
end
|
93
|
+
|
94
|
+
# A list of test names that can be passed to the command line utility.
|
95
|
+
#
|
96
|
+
# @return [Array]
|
97
|
+
def self.test_names
|
98
|
+
names = Dir.glob(File.join(test_path, 'test_*.rb'))
|
99
|
+
names.collect { |file| file.sub(/.*test_(.*)\.rb$/, '\1') }.sort
|
100
|
+
end
|
101
|
+
|
102
|
+
# Load a ruby source file with test script declarations and execute them.
|
103
|
+
#
|
104
|
+
# The `terminal_opts` and `enable_password` parameters will be passed to a
|
105
|
+
# {ASAConsole} factory method to generate a test object for each script run.
|
106
|
+
#
|
107
|
+
# @param test_file [String]
|
108
|
+
# @param terminal_opts [Hash]
|
109
|
+
# @param enable_password [String]
|
110
|
+
# @param show_session_log [Boolean]
|
111
|
+
# append the session log to the end of each test output if `true`
|
112
|
+
# @return [Boolean]
|
113
|
+
# `true` if `test_file` can be loaded with `require`, or `false` otherwise
|
114
|
+
def self.start(test_file, terminal_opts, enable_password = nil, show_session_log = false)
|
115
|
+
begin
|
116
|
+
require test_file
|
117
|
+
rescue LoadError
|
118
|
+
puts "Unable to load #{test_file}"
|
119
|
+
return false
|
120
|
+
end
|
121
|
+
|
122
|
+
@@scripts.each do |klass|
|
123
|
+
script = klass.new(terminal_opts, enable_password)
|
124
|
+
script.run
|
125
|
+
script.show_session_log if show_session_log
|
126
|
+
puts
|
127
|
+
puts colorize('Test Complete!', :info)
|
128
|
+
puts
|
129
|
+
end
|
130
|
+
|
131
|
+
true
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
|
2
|
+
class ASAConsole
|
3
|
+
module Test
|
4
|
+
#
|
5
|
+
# Parent class for {ASAConsole} test scripts.
|
6
|
+
#
|
7
|
+
class Script
|
8
|
+
# @private
|
9
|
+
def initialize(terminal_opts, enable_password = nil)
|
10
|
+
@asa = ASAConsole.ssh(terminal_opts)
|
11
|
+
@asa.enable_password = enable_password if enable_password
|
12
|
+
@asa.terminal.on_output do |prompt, input, output|
|
13
|
+
print Test.colorize(prompt, :prompt) if prompt
|
14
|
+
print Test.colorize(input, :input) if input
|
15
|
+
print Test.colorize(output, :output) if output
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# @api private
|
20
|
+
def run
|
21
|
+
test! @asa
|
22
|
+
rescue Exception => e
|
23
|
+
puts
|
24
|
+
puts Test.colorize('Received Exception:', :info)
|
25
|
+
puts ' ' + e.class.name
|
26
|
+
puts Test.colorize('Message:', :info)
|
27
|
+
puts ' ' + e.message
|
28
|
+
puts Test.colorize('Stack Trace:', :info)
|
29
|
+
puts e.backtrace.join("\n").gsub(/^/, ' ')
|
30
|
+
end
|
31
|
+
|
32
|
+
# @api private
|
33
|
+
def show_session_log
|
34
|
+
puts Test.colorize('Session Log:', :info)
|
35
|
+
puts @asa.terminal.session_log.gsub(/^/, ' ').chomp
|
36
|
+
puts
|
37
|
+
end
|
38
|
+
|
39
|
+
# Call from within a {script} block to output status messages.
|
40
|
+
#
|
41
|
+
# @see script
|
42
|
+
# @param text [String]
|
43
|
+
def log(text)
|
44
|
+
puts Test.colorize(text, :log)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
|
2
|
+
require 'stringio'
|
3
|
+
require 'asa_console/error'
|
4
|
+
|
5
|
+
class ASAConsole
|
6
|
+
#
|
7
|
+
# Miscellaneous utility functions.
|
8
|
+
#
|
9
|
+
module Util
|
10
|
+
CISCO_TIME_REGEX = %r{
|
11
|
+
(?<hour> \d\d):
|
12
|
+
(?<min> \d\d):
|
13
|
+
(?<sec> \d\d)
|
14
|
+
(?:\.(?<subsec> \d\d\d)\s)?
|
15
|
+
(?<tz> .*?)\s
|
16
|
+
(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s)?
|
17
|
+
(?<month> \w\w\w)\s
|
18
|
+
(?<day> \d\d?)\s
|
19
|
+
(?<year> \d\d\d\d)
|
20
|
+
}x
|
21
|
+
|
22
|
+
VERSION_EXPR_REGEX = %r{^
|
23
|
+
(?<opr> [><=!]=?)?\s* # Comparison operator
|
24
|
+
(?<major> \d+) (?: # Major release number
|
25
|
+
\.(?<minor> \d+|x) (?: # Minor release number or x
|
26
|
+
\((?<maint> \d+|x)\) # Maintenance release number or x
|
27
|
+
)?
|
28
|
+
)?
|
29
|
+
$}x
|
30
|
+
|
31
|
+
# Convert a string with terminal control characters to plain text as it
|
32
|
+
# would appear in a terminal window.
|
33
|
+
#
|
34
|
+
# An ASA will use backspaces and carriage returns to hide text that has
|
35
|
+
# already been output to the console. For example, the ASA outputs extra
|
36
|
+
# characters to hide the `<--- More --->` prompt when the user presses a
|
37
|
+
# key.
|
38
|
+
#
|
39
|
+
# @param raw [String]
|
40
|
+
# @return [String]
|
41
|
+
def self.apply_control_chars(raw)
|
42
|
+
output = ''
|
43
|
+
raw.split("\n").each do |line|
|
44
|
+
io = StringIO.new
|
45
|
+
line.scan(/([^\r\x08]+|[\r\x08])/) do |m|
|
46
|
+
case m[0]
|
47
|
+
when "\r"
|
48
|
+
io.rewind
|
49
|
+
when "\x08"
|
50
|
+
io.pos = io.pos - 1
|
51
|
+
else
|
52
|
+
io.write(m[0])
|
53
|
+
end
|
54
|
+
end
|
55
|
+
output << io.string << "\n"
|
56
|
+
end
|
57
|
+
output.chop! unless raw.end_with?("\n")
|
58
|
+
output.delete("\x00")
|
59
|
+
end
|
60
|
+
|
61
|
+
# Parse the time format commonly used in various command output. This can be
|
62
|
+
# useful for things like extracting the configuration modification time from
|
63
|
+
# "show version" output or for parsing the last failover time.
|
64
|
+
#
|
65
|
+
# @note
|
66
|
+
# It is not possible to reliably evaluate the timezone string without
|
67
|
+
# running additional commands, so this function (optimistically) returns
|
68
|
+
# a UTC timestamp. See {file:script/test_clock.rb} for one method of
|
69
|
+
# adjusting a remote timestamp to local time using "show clock" commands.
|
70
|
+
#
|
71
|
+
# @param str [String]
|
72
|
+
# @yieldparam time [Time]
|
73
|
+
# @yieldparam tz [String]
|
74
|
+
# timezone string set by "clock timezone" or "clock summer-time"
|
75
|
+
# @return [Time]
|
76
|
+
# time represented in UTC
|
77
|
+
def self.parse_cisco_time(str)
|
78
|
+
m = CISCO_TIME_REGEX.match(str)
|
79
|
+
return nil unless m
|
80
|
+
tz = m[:tz]
|
81
|
+
year = m[:year].to_i
|
82
|
+
month = m[:month]
|
83
|
+
day = m[:day].to_i
|
84
|
+
hour = m[:hour].to_i
|
85
|
+
min = m[:min].to_i
|
86
|
+
sec = m[:sec].to_i
|
87
|
+
subsec = "0.#{m[:subsec]}".to_f
|
88
|
+
time = Time.utc(year, month, day, hour, min, sec) + subsec
|
89
|
+
time = yield(time, tz) if block_given?
|
90
|
+
time
|
91
|
+
end
|
92
|
+
|
93
|
+
# Match an ASA software version string in `x.x(x)` format against one or
|
94
|
+
# more conditional expressions.
|
95
|
+
#
|
96
|
+
# @see #version? The ASAConsole wrapper for this function
|
97
|
+
# @param version [String]
|
98
|
+
# @param exprs [Array<String>]
|
99
|
+
# @return [Boolean] `true` if _all_ expressions match, or `false` otherwise
|
100
|
+
def self.version_match?(version, exprs)
|
101
|
+
ver = []
|
102
|
+
version_match_parse(version) { |_opr, pattern| ver = pattern }
|
103
|
+
exprs.each do |e|
|
104
|
+
version_match_parse(e) do |opr, pattern|
|
105
|
+
return false unless version_match_compare(opr, ver, pattern)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
true
|
109
|
+
end
|
110
|
+
|
111
|
+
# @api private
|
112
|
+
def self.version_match_parse(expr)
|
113
|
+
expr = expr.to_s # Forgive users who provide a number instead of a string
|
114
|
+
m = VERSION_EXPR_REGEX.match(expr)
|
115
|
+
fail Error::InvalidExpressionError, "Expression '#{expr}' is not valid" unless m
|
116
|
+
opr = m[:opr]
|
117
|
+
opr = '==' if opr == '=' || opr.nil? # Equality is the default operator
|
118
|
+
opr = '!=' if opr == '!'
|
119
|
+
pattern = [ m[:major].to_i ]
|
120
|
+
if m[:minor] && m[:minor] != 'x'
|
121
|
+
pattern << m[:minor].to_i
|
122
|
+
pattern << m[:maint].to_i if m[:maint] && m[:maint] != 'x'
|
123
|
+
end
|
124
|
+
yield(opr, pattern)
|
125
|
+
end
|
126
|
+
|
127
|
+
# @api private
|
128
|
+
def self.version_match_compare(opr, ver, pattern)
|
129
|
+
a = ver[0..(pattern.length - 1)]
|
130
|
+
b = pattern.clone
|
131
|
+
eq = true
|
132
|
+
gt = lt = false
|
133
|
+
a.each do |x|
|
134
|
+
y = b.shift
|
135
|
+
next if x == y
|
136
|
+
eq = false
|
137
|
+
gt = x > y
|
138
|
+
lt = x < y
|
139
|
+
break
|
140
|
+
end
|
141
|
+
case opr
|
142
|
+
when '>' then gt
|
143
|
+
when '<' then lt
|
144
|
+
when '==' then eq
|
145
|
+
when '>=' then gt || eq
|
146
|
+
when '<=' then lt || eq
|
147
|
+
when '!=' then !eq
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
|
2
|
+
#
|
3
|
+
# This script demonstrates a method for converting a firewall timestamp to a
|
4
|
+
# time in the local timezone. We have to explicitly query for the UTC offset
|
5
|
+
# since the timezone string can be set to any arbitrary value.
|
6
|
+
#
|
7
|
+
ASAConsole::Test.script do |asa|
|
8
|
+
asa.connect
|
9
|
+
|
10
|
+
log "Local time is #{Time.now.getlocal}"
|
11
|
+
|
12
|
+
time = ASAConsole::Util.parse_cisco_time(asa.show('clock')) do |t, tz|
|
13
|
+
log "Remote time (not adjusted for timezone) is #{t}"
|
14
|
+
log "Remote timezone string is #{tz}"
|
15
|
+
|
16
|
+
# We need to use "all clock" instead of "clock" since the timezone line is
|
17
|
+
# omitted from default output when using UTC.
|
18
|
+
result = asa.running_config('all clock').select('clock timezone').config_data
|
19
|
+
matches = /(?<tz>[\S]+) (?<offset>\-?\d+)/.match(result)
|
20
|
+
|
21
|
+
# It's technically possible for the summer time string to match the timezone
|
22
|
+
# string, but let's assume this firewall has a sane configuration.
|
23
|
+
if tz == matches[:tz]
|
24
|
+
log 'Assuming we are not on summer time...'
|
25
|
+
offset = matches[:offset].to_i
|
26
|
+
else
|
27
|
+
log 'Assuming we are on summer time...'
|
28
|
+
offset = matches[:offset].to_i + 1
|
29
|
+
end
|
30
|
+
|
31
|
+
log "UTC offset is #{offset}"
|
32
|
+
|
33
|
+
t.getlocal - (offset * 3600)
|
34
|
+
end
|
35
|
+
|
36
|
+
log "Remote time (adjusted to the local timezone) is #{time}"
|
37
|
+
|
38
|
+
asa.disconnect
|
39
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
|
2
|
+
#
|
3
|
+
# This test will raise a command error.
|
4
|
+
#
|
5
|
+
ASAConsole::Test.script do |asa|
|
6
|
+
log 'Connecting...'
|
7
|
+
asa.connect
|
8
|
+
|
9
|
+
log 'Generating an error...'
|
10
|
+
asa.priv_exec('derp derp derp')
|
11
|
+
|
12
|
+
# It won't get this far.
|
13
|
+
log 'Disconnecting...'
|
14
|
+
asa.disconnect
|
15
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
|
2
|
+
#
|
3
|
+
# The connection will fail with a timeout error.
|
4
|
+
#
|
5
|
+
ASAConsole::Test.script do |asa|
|
6
|
+
asa.terminal.host = '192.0.2.1' # Non-routable IP from RFC 5737
|
7
|
+
|
8
|
+
# We already know it's going to time out so there's no need to wait long.
|
9
|
+
asa.terminal.connect_timeout = 1
|
10
|
+
|
11
|
+
log 'Connecting to a non-routable IP...'
|
12
|
+
asa.connect
|
13
|
+
|
14
|
+
# It won't get this far.
|
15
|
+
log 'Disconnecting...'
|
16
|
+
asa.disconnect
|
17
|
+
end
|
18
|
+
|
19
|
+
#
|
20
|
+
# The connection will fail with an authentication error.
|
21
|
+
#
|
22
|
+
ASAConsole::Test.script do |asa|
|
23
|
+
asa.terminal.password = 'wrong password'
|
24
|
+
|
25
|
+
log 'Connecting with the wrong password...'
|
26
|
+
asa.connect
|
27
|
+
|
28
|
+
# It won't get this far.
|
29
|
+
log 'Disconnecting...'
|
30
|
+
asa.disconnect
|
31
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
|
2
|
+
#
|
3
|
+
# Test for failure when #connect attempts to enter privileged EXEC mode.
|
4
|
+
#
|
5
|
+
ASAConsole::Test.script do |asa|
|
6
|
+
asa.enable_password = 'bad enable password'
|
7
|
+
|
8
|
+
# The #connect method will time out waiting for a prompt ending in "#" because
|
9
|
+
# the next prompt it receives will be a second "Password:" prompt.
|
10
|
+
asa.terminal.command_timeout = 1
|
11
|
+
|
12
|
+
log 'Connecting with a bad enable password...'
|
13
|
+
asa.connect
|
14
|
+
|
15
|
+
# It won't get this far.
|
16
|
+
log 'Disconnecting...'
|
17
|
+
asa.disconnect
|
18
|
+
end
|