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.
@@ -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,10 @@
1
+
2
+ #
3
+ # A simple connection test.
4
+ #
5
+ ASAConsole::Test.script do |asa|
6
+ log 'Connecting...'
7
+ asa.connect
8
+ log 'Disconnecting...'
9
+ asa.disconnect
10
+ 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