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,170 @@
1
+
2
+ require 'stringio'
3
+
4
+ class ASAConsole
5
+ #
6
+ # A parser for ASA running/startup config.
7
+ #
8
+ # Each instance represents a single config entry which may include nested
9
+ # lines of config that can be queried with {#select} or {#select_all}.
10
+ #
11
+ # @example Print ACEs with line numbers for each access list
12
+ # # Start by fetching a top-level Config object
13
+ # config = asa.running_config('access-list')
14
+ # config.names_of('access-list').each do |acl|
15
+ # puts "Access list #{acl}:"
16
+ # counter = 0
17
+ # config.select('access-list', acl) do |ace|
18
+ # counter += 1
19
+ # puts " Line #{counter}: #{ace.line}"
20
+ # end
21
+ # puts
22
+ # end
23
+ #
24
+ # @example List all interfaces that participate in EIGRP
25
+ # # Start by fetching a top-level Config object
26
+ # config = asa.running_config('all router eigrp')
27
+ # router = config.select('router eigrp')
28
+ # router.select('passive-interface') do |passive_interface|
29
+ # ifname = passive_interface.config_data
30
+ # next if ifname == 'default'
31
+ # if passive_interface.negated?
32
+ # puts "Interface '#{ifname}' is sending routing updates"
33
+ # else
34
+ # puts "Interface '#{ifname}' is passive"
35
+ # end
36
+ # end if router
37
+ #
38
+ # @attr_reader keystr [String, nil]
39
+ # Key used to select the line of config represented by this object, or `nil`
40
+ # if the object contains nested top-level config.
41
+ # @attr_reader config_name [String, nil]
42
+ # An identifier, such as an access list name, used to distinguish between
43
+ # config entries of the same type.
44
+ # @attr_reader config_data [String, nil]
45
+ # Remainder of the config line following the {#keystr} and {#config_name}.
46
+ #
47
+ class Config
48
+ attr_reader :keystr
49
+ attr_reader :config_name
50
+ attr_reader :config_data
51
+
52
+ # @see #running_config
53
+ # @option opts [String] :keystr class attribute
54
+ # @option opts [String] :config_name class attribute
55
+ # @option opts [String] :config_data class attribute
56
+ # @option opts [Boolean] :negated
57
+ # `true` if the config line began with "no", or `false` otherwise
58
+ # @option opts [String] :nested_config
59
+ # a multiline string of config (indentation stripped)
60
+ def initialize(opts = {})
61
+ @keystr = opts[:keystr]
62
+ @config_name = opts[:config_name]
63
+ @config_data = opts[:config_data]
64
+ @negated = opts.fetch(:negated, false)
65
+ @nested_config = opts.fetch(:nested_config, '')
66
+ end
67
+
68
+ # @return [String, nil]
69
+ # the selected line, or `nil` if this is a top-level object
70
+ def line
71
+ parts = []
72
+ parts << 'no' if @negated
73
+ parts << @keystr if @keystr
74
+ parts << @config_name if @config_name
75
+ parts << @config_data if @config_data
76
+ parts.empty? ? nil : parts.join(' ')
77
+ end
78
+
79
+ # @return [Boolean]
80
+ # `true` if the selected line began with "no", or `false` otherwise
81
+ def negated?
82
+ @negated ? true : false
83
+ end
84
+
85
+ # @return [Boolean]
86
+ # `true` if there is no nested config, or `false` otherwise
87
+ def empty?
88
+ @nested_config.empty?
89
+ end
90
+
91
+ # Select all lines of nested config. Equivalent to {#select} with no
92
+ # arguments.
93
+ #
94
+ # @yieldparam config [Config]
95
+ # @yieldreturn [nil] if a block is given
96
+ # @return [Array<Config>] if no block given
97
+ def select_all
98
+ result = []
99
+ select do |config|
100
+ if block_given?
101
+ yield config
102
+ else
103
+ result << config
104
+ end
105
+ end
106
+ result unless block_given?
107
+ end
108
+
109
+ # Select the first matching line of the nested config or `yield` all
110
+ # matching lines if a block is given.
111
+ #
112
+ # @param keystr [String, nil]
113
+ # @param config_name [String, nil]
114
+ # @yieldparam config [Config]
115
+ # @yieldreturn [nil] if a block is given
116
+ # @return [Config] if no block given
117
+ def select(keystr = nil, config_name = nil)
118
+ prefix = [keystr, config_name].join(' ').strip
119
+ regex = /^(?<no>no )?#{Regexp.escape(prefix)} ?(?<data>.+)?/
120
+
121
+ io = StringIO.open(@nested_config)
122
+ lines = io.readlines
123
+ io.close
124
+
125
+ loop do
126
+ break if lines.empty?
127
+
128
+ m = regex.match(lines.shift)
129
+ next unless m
130
+
131
+ nested_config = ''
132
+ loop do
133
+ break unless lines[0] && lines[0].start_with?(' ')
134
+ nested_config << lines.shift.sub(/^ /, '')
135
+ end
136
+
137
+ config = Config.new(
138
+ keystr: keystr,
139
+ config_name: config_name,
140
+ config_data: m[:data],
141
+ negated: !m[:no].nil?,
142
+ nested_config: nested_config
143
+ )
144
+
145
+ if block_given?
146
+ yield config
147
+ else
148
+ return config
149
+ end
150
+ end
151
+
152
+ nil
153
+ end
154
+
155
+ # @see #select
156
+ # @param keystr [String]
157
+ # @return [Array<String>]
158
+ # a unique list of config element names matched by `keystr`
159
+ def names_of(keystr)
160
+ names = []
161
+ regex = /^(?:no )?#{Regexp.escape(keystr)} (?<name>\S+)/
162
+ @nested_config.each_line do |line|
163
+ m = regex.match(line)
164
+ next unless m
165
+ names << m[:name] unless names.include? m[:name]
166
+ end
167
+ names
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,43 @@
1
+
2
+ class ASAConsole
3
+ # Parent class for all other {ASAConsole} exceptions.
4
+ class Exception < ::RuntimeError; end
5
+
6
+ # Container for exceptions to avoid cluttering the namespace.
7
+ module Error
8
+ # Raised when an unexpected "ERROR: [...]" message is received.
9
+ class CommandError < Exception; end
10
+
11
+ # Raised when attempting to execute a configuration command in the wrong
12
+ # config mode or submode.
13
+ class ConfigModeError < Exception; end
14
+
15
+ # Any type of connection failure.
16
+ class ConnectFailure < Exception; end
17
+
18
+ # Raised when a terminal object times out waiting for an expected prompt.
19
+ class ExpectedPromptFailure < Exception; end
20
+
21
+ # Raised when checking an ASA version against an unsupported expression.
22
+ class InvalidExpressionError < Exception; end
23
+
24
+ # Raised when a required option hash entry is missing.
25
+ class MissingOptionError < Exception; end
26
+
27
+ # Raised when attempting to execute a command on a disconnected terminal.
28
+ class NotConnectedError < Exception; end
29
+
30
+ # Raised (by default) when a configuration command generates output instead
31
+ # of just presenting the next config prompt.
32
+ class UnexpectedOutputError < Exception; end
33
+
34
+ # Raised when there is a failure parsing the appliance version string.
35
+ class VersionParseError < Exception; end
36
+
37
+ # A {ConnectFailure} caused by invalid credentials.
38
+ class AuthenticationFailure < ConnectFailure; end
39
+
40
+ # A {ConnectFailure} caused by a timeout.
41
+ class ConnectionTimeoutError < ConnectFailure; end
42
+ end
43
+ end
@@ -0,0 +1,9 @@
1
+
2
+ class ASAConsole
3
+ #
4
+ # Container for duck-typed classes that provide an interactive command line
5
+ # session of some kind.
6
+ #
7
+ module Terminal
8
+ end
9
+ end
@@ -0,0 +1,145 @@
1
+
2
+ require 'asa_console/terminal/ssh'
3
+
4
+ class ASAConsole
5
+ module Terminal
6
+ #
7
+ # A subclass of {SSH} that overrides the {SSH#connect} method to generate
8
+ # stub objects for RSpec testing.
9
+ #
10
+ # The constructor takes the same hash options as {SSH#initialize}, but
11
+ # requires one additional option; `:input_proc`, a proc that takes two
12
+ # arguments:
13
+ # * `input` -- A command or other input that is being passed to the
14
+ # simulated appliance.
15
+ # * `prompt` -- Last prompt displayed on the terminal when the `input`
16
+ # text was entered.
17
+ #
18
+ # The proc is expected to return an array with three elements:
19
+ # * `output` -- Result of sending `input` to the simulated appliance.
20
+ # * `prompt` -- Next prompt to present within the session.
21
+ # * `disconnect` -- `true` to indicate that the SSH connection has been
22
+ # closed (e.g. if "exit" was sent to the terminal), or `false`
23
+ # otherwise.
24
+ #
25
+ # @example
26
+ # input_proc = proc do |input|
27
+ # prompt = 'TEST# '
28
+ # case input
29
+ # when "terminal pager lines 0\n", nil
30
+ # output = prompt
31
+ # disconnect = false
32
+ # when "exit\n"
33
+ # output = "\nLogoff\n\n"
34
+ # disconnect = true
35
+ # else
36
+ # output = "ERROR: Command not implemented\n" + prompt
37
+ # disconnect = false
38
+ # end
39
+ # [output, prompt, disconnect]
40
+ # end
41
+ #
42
+ # asa = ASAConsole.fake_ssh(
43
+ # host: 'ignored',
44
+ # user: 'ignored',
45
+ # input_proc: input_proc
46
+ # )
47
+ # asa.connect
48
+ #
49
+ # # This will raise an error
50
+ # asa.priv_exec('write memory')
51
+ #
52
+ # @api development
53
+ class FakeSSH < SSH
54
+ #
55
+ # @api private
56
+ attr_accessor :raw_buffer
57
+ #
58
+ # @api private
59
+ attr_accessor :raw_session_log
60
+ #
61
+ # @api private
62
+ attr_accessor :session
63
+ #
64
+ # @api private
65
+ attr_accessor :channel
66
+ #
67
+ # @api private
68
+ attr_accessor :last_output_received
69
+ #
70
+ # @api private
71
+ attr_accessor :input_proc
72
+
73
+ # Wrapper for the {SSH} class constructor that extracts `:input_proc` from
74
+ # the options hash and passes the rest to the parent.
75
+ #
76
+ # @option opts [Proc] :input_proc
77
+ # @option opts ... options for the {SSH} constructor
78
+ # @see SSH#initialize SSH constructor
79
+ def initialize(opts)
80
+ @input_proc = opts.delete(:input_proc)
81
+ fail Error::MissingOptionError, 'Option :input_proc is missing or invalid' \
82
+ unless @input_proc.is_a? Proc
83
+ super(opts)
84
+ end
85
+
86
+ # Connect to a simulated appliance. This method creates anonymous stub
87
+ # classes that take the place of `Net::SSH::Connection::Session` and
88
+ # `Net::SSH::Connection::Channel`. The stub classes implement only the
89
+ # subset of methods that are used by the {SSH} terminal class.
90
+ #
91
+ # @return [void]
92
+ def connect
93
+ @session = Class.new do
94
+ def initialize
95
+ @closed = false
96
+ end
97
+ def close
98
+ @closed = true
99
+ end
100
+ def closed?
101
+ @closed ? true : false
102
+ end
103
+ end.new
104
+
105
+ @channel = Class.new do
106
+ attr_reader :terminal
107
+ def initialize(terminal)
108
+ @terminal = terminal
109
+ @input_buffer = ''
110
+ @output_buffer, @prompt = @terminal.input_proc.call
111
+ end
112
+ def connection
113
+ self
114
+ end
115
+ def process(timeout)
116
+ sleep timeout
117
+ @terminal.raw_buffer = @output_buffer
118
+ @terminal.raw_session_log << @output_buffer
119
+ @output_buffer = ''
120
+ fail Net::SSH::Disconnect if @terminal.session.closed?
121
+ end
122
+ def send_data(input)
123
+ @input_buffer << input
124
+ return unless @input_buffer.end_with? "\n"
125
+ @output_buffer << @input_buffer
126
+ output, prompt, disconnect = @terminal.input_proc.call(@input_buffer, @prompt)
127
+ @output_buffer << output
128
+ @prompt = prompt
129
+ @terminal.session.close if disconnect
130
+ @input_buffer = ''
131
+ @terminal.last_output_received = Time.now.getlocal
132
+ end
133
+ end.new(self)
134
+
135
+ @connected = true
136
+
137
+ # This is copied verbatim from the parent #connect method
138
+ expect(ANY_EXEC_PROMPT) do |success, output|
139
+ @on_output_callbacks.each { |c| c.call(nil, nil, output) }
140
+ fail Error::ConnectFailure 'Failed to parse EXEC prompt', self unless success
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,223 @@
1
+
2
+ require 'net/ssh'
3
+ require 'asa_console/error'
4
+ require 'asa_console/terminal'
5
+ require 'asa_console/util'
6
+
7
+ class ASAConsole
8
+ module Terminal
9
+ #
10
+ # An SSH terminal session.
11
+ #
12
+ # @attr host [String]
13
+ # Hostname or IP address.
14
+ # @attr user [String]
15
+ # SSH username.
16
+ # @attr password [String]
17
+ # SSH password.
18
+ # @attr ssh_opts [Hash]
19
+ # Option hash passed to `Net::SSH::start`.
20
+ # @attr command_timeout [Numeric]
21
+ # Maximum time to wait for a command to execute.
22
+ # @attr connect_timeout [Numeric]
23
+ # SSH connection timeout. (`:timeout` option passed to `Net::SSH::start`)
24
+ # @attr_reader prompt [String, nil]
25
+ # Prompt currently displayed on the terminal or `nil` if not connected.
26
+ #
27
+ class SSH
28
+ DEFAULT_CONNECT_TIMEOUT = 5
29
+ DEFAULT_COMMAND_TIMEOUT = 5
30
+
31
+ attr_accessor :host
32
+ attr_accessor :user
33
+ attr_accessor :ssh_opts
34
+ attr_accessor :command_timeout
35
+
36
+ attr_reader :prompt
37
+
38
+ # @see Terminal
39
+ # @option opts [String] :host
40
+ # @option opts [String] :user
41
+ # @option opts [String] :password
42
+ # @option opts [Numeric] :connect_timeout
43
+ # @option opts [Numeric] :command_timeout
44
+ def initialize(opts)
45
+ fail Error::MissingOptionError, 'Option :host is missing' unless opts[:host]
46
+ fail Error::MissingOptionError, 'Option :user is missing' unless opts[:user]
47
+
48
+ @host = opts[:host]
49
+ @user = opts[:user]
50
+ @ssh_opts = {
51
+ timeout: opts.fetch(:connect_timeout, DEFAULT_CONNECT_TIMEOUT),
52
+ number_of_password_prompts: 0 # Avoid prompting for password on authentication failure
53
+ }
54
+ self.password = opts[:password]
55
+ @command_timeout = opts.fetch(:command_timeout, DEFAULT_COMMAND_TIMEOUT)
56
+ @prompt = nil
57
+
58
+ @raw_buffer = ''
59
+ @raw_session_log = ''
60
+ @connected = false
61
+ @channel = nil
62
+ @session = nil
63
+ @last_output_received = nil
64
+ @on_output_callbacks = []
65
+ end
66
+
67
+ def password
68
+ @ssh_opts[:password]
69
+ end
70
+
71
+ def password=(str)
72
+ @ssh_opts[:password] = str
73
+ @ssh_opts[:auth_methods] = ['password'] if str
74
+ end
75
+
76
+ def connect_timeout
77
+ @ssh_opts[:timeout]
78
+ end
79
+
80
+ def connect_timeout=(timeout)
81
+ @ssh_opts[:timeout] = timeout
82
+ end
83
+
84
+ # Start an SSH session and send a remote shell request. The method blocks
85
+ # until an EXEC prompt is received or a timeout is reached.
86
+ #
87
+ # @see https://tools.ietf.org/html/rfc4254 RFC 4254
88
+ # @raise [Error::ConnectFailure] for all error types
89
+ # @raise [Error::AuthenticationFailure]
90
+ # subclass of {Error::ConnectFailure}
91
+ # @raise [Error::ConnectionTimeoutError]
92
+ # subclass of {Error::ConnectFailure}
93
+ # @return [void]
94
+ def connect
95
+ Net::SSH.start(@host, @user, @ssh_opts) do |session|
96
+ @session = session
97
+ @session.open_channel do |channel|
98
+ channel.send_channel_request('shell') do |ch, ch_success|
99
+ fail Error::ConnectFailure, 'Failed to start remote shell' unless ch_success
100
+ @connected = true
101
+ @channel = ch
102
+ @channel.on_data do |_ch, data|
103
+ @last_output_received = Time.now.getlocal
104
+ @raw_session_log << data
105
+ @raw_buffer << data
106
+ end
107
+ @channel.on_close do
108
+ @connected = false
109
+ end
110
+ expect(ANY_EXEC_PROMPT) do |success, output|
111
+ @on_output_callbacks.each { |c| c.call(nil, nil, output) }
112
+ fail Error::ConnectFailure, 'Failed to parse EXEC prompt', self unless success
113
+ end
114
+ return # Workaround for Net::SSH limitations borrowed from Puppet
115
+ end
116
+ end
117
+ end
118
+ rescue Timeout::Error
119
+ raise Error::ConnectionTimeoutError, "Timeout connecting to #{@host}"
120
+ rescue Net::SSH::AuthenticationFailed
121
+ raise Error::AuthenticationFailure, "Authentication failed for #{@user}@#{@host}"
122
+ rescue SystemCallError, SocketError => e
123
+ raise Error::ConnectFailure, "#{e.class}: #{e.message}"
124
+ end
125
+
126
+ # @return [Boolean]
127
+ def connected?
128
+ @connected = false if @session.nil? || @session.closed?
129
+ @connected
130
+ end
131
+
132
+ # @return [void]
133
+ def disconnect
134
+ @session.close if connected?
135
+ rescue Net::SSH::Disconnect
136
+ @session = nil
137
+ end
138
+
139
+ # Send a line of text to the console and block until the expected prompt
140
+ # is seen in the output or a timeout is reached.
141
+ #
142
+ # @note
143
+ # Special characters are not escaped by this method. Use the
144
+ # {ASAConsole} wrapper for unescaped text.
145
+ #
146
+ # @see ASAConsole#send ASAConsole wrapper for this method
147
+ # @param line [String]
148
+ # @param expect_regex [Regexp]
149
+ # @param is_password [Boolean]
150
+ # @yieldparam success [Boolean]
151
+ # @yieldparam output [String]
152
+ # @return [void]
153
+ def send(line, expect_regex, is_password = false)
154
+ last_prompt = @prompt
155
+ @channel.send_data "#{line}\n" if connected?
156
+ input = (is_password ? '*' * line.length : line) + "\n"
157
+ expect(expect_regex) do |success, output|
158
+ output = output.sub(/^[^\n]*\n/m, '') # Remove echoed input
159
+ @on_output_callbacks.each { |c| c.call(last_prompt, input, output) }
160
+ yield(success, output)
161
+ end
162
+ end
163
+
164
+ # Register a proc to be called whenever the {#send} method finishes
165
+ # processing a transaction (whether successful or not).
166
+ #
167
+ # @example
168
+ # @command_log = []
169
+ # asa.terminal.on_output do |prompt, command, output|
170
+ # if prompt && prompt !~ ASAConsole::PASSWORD_PROMPT
171
+ # @command_log << command
172
+ # end
173
+ # end
174
+ #
175
+ # @yieldparam prompt [String]
176
+ # @yieldparam command [String]
177
+ # @yieldparam output [String]
178
+ # @return [void]
179
+ def on_output(&block)
180
+ @on_output_callbacks << block
181
+ end
182
+
183
+ # @return [String] a complete log of the SSH session
184
+ def session_log
185
+ Util.apply_control_chars(@raw_session_log)
186
+ end
187
+
188
+ def buffer
189
+ Util.apply_control_chars(@raw_buffer)
190
+ end
191
+ protected :buffer
192
+
193
+ def expect(prompt_regex)
194
+ @last_output_received = Time.now.getlocal
195
+
196
+ while buffer !~ prompt_regex
197
+ begin
198
+ @channel.connection.process(0.1)
199
+ break if Time.now.getlocal - @last_output_received > @command_timeout
200
+ rescue Net::SSH::Disconnect, IOError
201
+ @connected = false
202
+ break
203
+ end
204
+ end
205
+
206
+ matches = prompt_regex.match(buffer)
207
+ if matches.nil?
208
+ success = false
209
+ @prompt = nil
210
+ else
211
+ success = true
212
+ @prompt = matches[0]
213
+ end
214
+
215
+ output = buffer.sub(prompt_regex, '')
216
+ @raw_buffer = ''
217
+
218
+ yield(success, output)
219
+ end
220
+ protected :expect
221
+ end
222
+ end
223
+ end