shells 0.1.5

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,400 @@
1
+ require 'base64'
2
+ require 'json'
3
+
4
+ module Shells
5
+
6
+ ##
7
+ # Common functionality for interacting with a pfSense device.
8
+ module PfSenseCommon
9
+
10
+ ##
11
+ # An error raised when we fail to navigate the pfSense menu.
12
+ class MenuNavigationFailure < Shells::ShellError
13
+
14
+ end
15
+
16
+ ##
17
+ # Failed to locate the public key.
18
+ class PublicKeyNotFound < Shells::ShellError
19
+
20
+ end
21
+
22
+ ##
23
+ # Failed to validate the public key.
24
+ class PublicKeyInvalid < Shells::ShellError
25
+
26
+ end
27
+
28
+ ##
29
+ # Failed to locate the user on the device.
30
+ class UserNotFound < Shells::ShellError
31
+
32
+ end
33
+
34
+ ##
35
+ # Signals that we want to restart the device.
36
+ class RestartNow < Exception
37
+
38
+ end
39
+
40
+
41
+ ##
42
+ # The base shell used when possible.
43
+ BASE_SHELL = '/bin/sh'
44
+
45
+ ##
46
+ # The pfSense shell itself.
47
+ PF_SHELL = '/usr/local/sbin/pfSsh.php'
48
+
49
+ ##
50
+ # The prompt in the pfSense shell.
51
+ PF_PROMPT = 'pfSense shell:'
52
+
53
+ ##
54
+ # Gets the version of the pfSense firmware.
55
+ attr_accessor :pf_sense_version
56
+
57
+ ##
58
+ # Gets the user currently logged into the pfSense device.
59
+ attr_accessor :pf_sense_user
60
+
61
+ ##
62
+ # Gets the hostname of the pfSense device.
63
+ attr_accessor :pf_sense_host
64
+
65
+
66
+ def line_ending #:nodoc:
67
+ "\n"
68
+ end
69
+
70
+ def self.included(base) #:nodoc:
71
+
72
+ # Trap the RestartNow exception.
73
+ # When encountered, change the :quit option to '/sbin/reboot'.
74
+ # This requires rewriting the @options instance variable since the hash is frozen
75
+ # after initial validation.
76
+ base.on_exception do |shell, ex|
77
+ if ex.is_a?(Shells::PfSenseCommon::RestartNow)
78
+ shell.send(:change_quit, '/sbin/reboot')
79
+ true
80
+ else
81
+ false
82
+ end
83
+ end
84
+
85
+ end
86
+
87
+ def validate_options #:nodoc:
88
+ super
89
+ options[:shell] = :shell
90
+ options[:prompt] = 'pfSense shell:'
91
+ options[:quit] = 'exit'
92
+ options[:retrieve_exit_code] = false
93
+ options[:on_non_zero_exit_code] = :ignore
94
+ options[:override_set_prompt] = ->(sh) { true }
95
+ options[:override_get_exit_code] = ->(sh) { 0 }
96
+ end
97
+
98
+ def exec_shell(&block) #:nodoc:
99
+ super do
100
+ # We want to drop to the shell before executing the block.
101
+ # So we'll navigate the menu to get the option for the shell.
102
+ # For this first navigation we allow a delay only if we are not connected to a serial device.
103
+ # Serial connections are always on, so they don't need to initialize first.
104
+ menu_option = get_menu_option 'Shell', !(Shells::SerialSession > self.class)
105
+ raise MenuNavigationFailure unless menu_option
106
+
107
+ # For 2.3 and 2.4 this is a valid match.
108
+ # If future versions change the default prompt, we need to change our process.
109
+ # [VERSION][USER@HOSTNAME]/root: where /root is the current dir.
110
+ shell_regex = /\[(?<VER>[^\]]*)\]\[(?<USERHOST>[^\]]*)\](?<CD>\/.*):\s*$/
111
+
112
+ # Now we execute the menu option and wait for the shell_regex to match.
113
+ temporary_prompt(shell_regex) do
114
+ exec menu_option.to_s, command_timeout: 5
115
+
116
+ # Once we have a match we should be able to repeat it and store the information from the shell.
117
+ data = prompt_match.match(combined_output)
118
+ self.pf_sense_version = data['VER']
119
+ self.pf_sense_user, _, self.pf_sense_host = data['USERHOST'].partition('@')
120
+ end
121
+
122
+ block.call
123
+
124
+ # Wait for the shell_regex to match again.
125
+ temporary_prompt(shell_regex) { wait_for_prompt nil, 4, false }
126
+
127
+ # Exit the shell to return to the menu.
128
+ send_data 'exit' + line_ending
129
+
130
+ # After the block we want to know what the Logout option is and we change the quit command to match.
131
+ menu_option = get_menu_option 'Logout'
132
+ raise MenuNavigationFailure unless menu_option
133
+ change_quit menu_option.to_s
134
+ end
135
+ end
136
+
137
+ def exec_prompt(&block) #:nodoc:
138
+ debug 'Initializing pfSense shell...'
139
+ exec '/usr/local/sbin/pfSsh.php', command_timeout: 5
140
+ begin
141
+ block.call
142
+ ensure
143
+ debug 'Quitting pfSense shell...'
144
+ send_data 'exit' + line_ending
145
+ end
146
+ end
147
+
148
+ ##
149
+ # Executes a series of commands on the pfSense shell.
150
+ def pf_exec(*commands)
151
+ ret = ''
152
+ commands.each { |cmd| ret += exec(cmd) }
153
+ ret + exec('exec')
154
+ end
155
+
156
+ ##
157
+ # Reloads the pfSense configuration on the device.
158
+ def parse_config
159
+ pf_exec 'parse_config(true);'
160
+ @config_parsed = true
161
+ end
162
+
163
+ ##
164
+ # Determines if the configuration has been parsed during this session.
165
+ def config_parsed?
166
+ instance_variable_defined?(:@config_parsed) && instance_variable_get(:@config_parsed)
167
+ end
168
+
169
+ ##
170
+ # Gets a configuration section from the pfSense device.
171
+ def get_config_section(section_name)
172
+ parse_config unless config_parsed?
173
+ JSON.parse pf_exec("echo json_encode($config[#{section_name.to_s.inspect}]);").strip
174
+ end
175
+
176
+ ##
177
+ # Sets a configuration section to the pfSense device.
178
+ #
179
+ # Returns the number of changes made to the configuration.
180
+ def set_config_section(section_name, values, message = '')
181
+ current_values = get_config_section(section_name)
182
+ changes = generate_config_changes("$config[#{section_name.to_s.inspect}]", current_values, values)
183
+ if changes&.any?
184
+ if message.to_s.strip == ''
185
+ message = "Updating #{section_name} section."
186
+ end
187
+ changes << "write_config(#{message.inspect});"
188
+
189
+ pf_exec(*changes)
190
+
191
+ (changes.size - 1)
192
+ else
193
+ 0
194
+ end
195
+ end
196
+
197
+ ##
198
+ # Apply the firewall configuration.
199
+ #
200
+ # You need to apply the firewall configuration after you make changes to aliases, NAT rules, or filter rules.
201
+ def apply_filter_config
202
+ pf_exec(
203
+ 'require_once("shaper.inc");',
204
+ 'require_once("filter.inc");',
205
+ 'filter_configure_sync();'
206
+ )
207
+ end
208
+
209
+ ##
210
+ # Applies the user configuration for the specified user.
211
+ def apply_user_config(user_id)
212
+ user_id = user_id.to_i
213
+ pf_exec(
214
+ 'require_once("auth.inc");',
215
+ "$user_entry = $config[\"system\"][\"user\"][#{user_id}];",
216
+ '$user_groups = array();',
217
+ 'foreach ($config["system"]["group"] as $gidx => $group) {',
218
+ ' if (is_array($group["member"])) {',
219
+ " if (in_array(#{user_id}, $group[\"member\"])) { $user_groups[] = $group[\"name\"]; }",
220
+ ' }',
221
+ '}',
222
+ # Intentionally run set_groups before and after to ensure group membership gets fully applied.
223
+ 'local_user_set_groups($user_entry, $user_groups);',
224
+ 'local_user_set($user_entry);',
225
+ 'local_user_set_groups($user_entry, $user_groups);'
226
+ )
227
+ end
228
+
229
+ ##
230
+ # Enabled public key authentication for the current pfSense user.
231
+ #
232
+ # Once this has been done you should be able to connect without using a password.
233
+ def enable_cert_auth(public_key = '~/.ssh/id_rsa.pub')
234
+ cert_regex = /^ssh-[rd]sa (?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)? \S*$/m
235
+
236
+ # get our cert unless the user provided a full cert for us.
237
+ unless public_key =~ cert_regex
238
+ public_key = File.expand_path(public_key)
239
+ if File.exist?(public_key)
240
+ public_key = File.read(public_key).to_s.strip
241
+ else
242
+ raise Shells::PfSenseCommon::PublicKeyNotFound
243
+ end
244
+ raise Shells::PfSenseCommon::PublicKeyInvalid unless public_key =~ cert_regex
245
+ end
246
+
247
+ cfg = get_config_section 'system'
248
+ user_id = nil
249
+ user_name = options[:user].downcase
250
+ cfg['user'].each_with_index do |user,index|
251
+ if user['name'].downcase == user_name
252
+ user_id = index
253
+
254
+ authkeys = Base64.decode64(user['authorizedkeys'].to_s).gsub("\r\n", "\n").strip
255
+ unless authkeys == '' || authkeys =~ cert_regex
256
+ warn "Existing authorized keys for user #{options[:user]} are invalid and are being reset."
257
+ authkeys = ''
258
+ end
259
+
260
+ if authkeys == ''
261
+ user['authorizedkeys'] = Base64.strict_encode64(public_key)
262
+ else
263
+ authkeys = authkeys.split("\n")
264
+ unless authkeys.include?(public_key)
265
+ authkeys << public_key unless authkeys.include?(public_key)
266
+ user['authorizedkeys'] = Base64.strict_encode64(authkeys.join("\n"))
267
+ end
268
+ end
269
+
270
+ break
271
+ end
272
+ end
273
+
274
+
275
+ raise Shells::PfSenseCommon::UserNotFound unless user_id
276
+
277
+ set_config_section 'system', cfg, "Enable certificate authentication for #{options[:user]}."
278
+
279
+ apply_user_config user_id
280
+ end
281
+
282
+
283
+ ##
284
+ # Exits the shell session immediately and requests a reboot of the pfSense device.
285
+ def reboot
286
+ raise Shells::SessionCompleted if session_complete?
287
+ raise Shells::PfSenseCommon::RestartNow
288
+ end
289
+
290
+ ##
291
+ # Exits the shell session immediately.
292
+ def quit
293
+ raise Shells::SessionCompleted if session_complete?
294
+ raise Shells::ShellBase::QuitNow
295
+ end
296
+
297
+
298
+
299
+ private
300
+
301
+ def generate_config_changes(prefix, old_value, new_value)
302
+ old_value = fix_config_arrays(old_value)
303
+ new_value = fix_config_arrays(new_value)
304
+
305
+ if new_value.is_a?(Hash)
306
+ changes = []
307
+
308
+ unless old_value.is_a?(Hash)
309
+ # make sure the value is an array now.
310
+ changes << "#{prefix} = array();"
311
+ # and change the old_value to be an empty hash so we can work with it.
312
+ old_value = {}
313
+ end
314
+
315
+ # now iterate the hashes and process the child elements.
316
+ new_value.each do |k, new_v|
317
+ old_v = old_value[k]
318
+ changes += generate_config_changes("#{prefix}[#{k.inspect}]", old_v, new_v)
319
+ end
320
+
321
+ changes
322
+ else
323
+ if new_value != old_value
324
+ if new_value.nil?
325
+ [ "unset #{prefix};" ]
326
+ else
327
+ [ "#{prefix} = #{new_value.inspect};" ]
328
+ end
329
+ else
330
+ [ ]
331
+ end
332
+ end
333
+ end
334
+
335
+ def fix_config_arrays(value)
336
+ if value.is_a?(Array)
337
+ value.each_with_index
338
+ .map{|v,i| [i,v]}.to_h # convert to hash
339
+ .inject({}){ |m,(k,v)| m[k.to_s] = v; m } # stringify keys
340
+ elsif value.is_a?(Hash)
341
+ value.inject({}) { |m,(k,v)| m[k.to_s] = v; m } # stringify keys
342
+ else
343
+ value
344
+ end
345
+ end
346
+
347
+
348
+
349
+ # Processes the pfSense console menu to determine the option to send.
350
+ def get_menu_option(option_text, delay = true)
351
+ option_regex = /\s(\d+)\)\s*#{option_text}\s/i
352
+ prompt_text = 'Enter an option:'
353
+
354
+ temporary_prompt prompt_text do
355
+ begin
356
+
357
+ # give the prompt a few seconds to draw.
358
+ if delay
359
+ wait_for_prompt(nil, 4, false)
360
+ end
361
+
362
+ # See if we have a menu already.
363
+ menu_regex = /(?<MENU>\s0\)(?:.|\r|\n(?!\s0\)))*)#{prompt_text}[ \t]*$/
364
+ match = menu_regex.match(combined_output)
365
+ menu = match ? match['MENU'] : nil
366
+
367
+ push_buffer
368
+
369
+ if menu.nil?
370
+ # We want to redraw the menu.
371
+ # In order to do that, we need to send a command that is not valid.
372
+ # A blank line equates to a zero, which is (probably) the logout option.
373
+ # So we'll send a -1 to redraw the menu without actually running any commands.
374
+ debug 'Redrawing menu...'
375
+ menu = exec('-1', command_timeout: 5, timeout_error: false)
376
+
377
+ if last_exit_code == :timeout
378
+ # If for some reason the shell is/was running, we need to exit it to return to the menu.
379
+ # This time we will raise an error.
380
+ menu = exec('exit', command_timeout: 5)
381
+ end
382
+ end
383
+
384
+ # Ok, so now we have our menu options.
385
+ debug "Locating 'XX) #{option_text}' menu option..."
386
+ match = option_regex.match(menu)
387
+ if match
388
+ return match[1].to_i
389
+ else
390
+ return nil
391
+ end
392
+ ensure
393
+ pop_discard_buffer
394
+ end
395
+ end
396
+ end
397
+
398
+
399
+ end
400
+ end
@@ -0,0 +1,55 @@
1
+ require 'shells/serial_session'
2
+ require 'shells/pf_sense_common'
3
+
4
+ module Shells
5
+
6
+ ##
7
+ # Executes a serial session with a pfSense host.
8
+ #
9
+ # Valid options:
10
+ # +path+::
11
+ # The path to the serial device (e.g. - COM3 or /dev/tty2)
12
+ # This is a required option.
13
+ # +speed+::
14
+ # The bitrate for the connection.
15
+ # The default is 115200.
16
+ # +data_bits+::
17
+ # The number of data bits for the connection.
18
+ # The default is 8.
19
+ # +parity+::
20
+ # The parity for the connection.
21
+ # The default is :none.
22
+ # +silence_timeout+::
23
+ # When a command is executing, this is the maximum amount of time to wait for any feedback from the shell.
24
+ # If set to 0 (or less) there is no timeout.
25
+ # Unlike +command_timeout+ this value resets every time we receive feedback.
26
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
27
+ # +command_timeout+::
28
+ # When a command is executing, this is the maximum amount of time to wait for the command to finish.
29
+ # If set to 0 (or less) there is no timeout.
30
+ # Unlike +silence_timeout+ this value does not reset when we receive feedback.
31
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
32
+ #
33
+ # Shells::PfSenseSerialSession.new(
34
+ # path: 'COM3'
35
+ # ) do |shell|
36
+ # cfg = shell.get_config_section("aliases")
37
+ # cfg["alias"] ||= []
38
+ # cfg["alias"] << {
39
+ # :name => "MY_NETWORK",
40
+ # :type => "network",
41
+ # :address => "192.168.1.0/24",
42
+ # :descr => "My home network",
43
+ # :details => "Created #{Time.now.to_s}"
44
+ # }
45
+ # shell.set_config_section("aliases", cfg, "Add home network")
46
+ # shell.apply_filter_config
47
+ # end
48
+ #
49
+ class PfSenseSerialSession < SerialSession
50
+
51
+ include PfSenseCommon
52
+
53
+ end
54
+
55
+ end
@@ -0,0 +1,56 @@
1
+ require 'shells/ssh_session'
2
+ require 'shells/pf_sense_common'
3
+
4
+ module Shells
5
+
6
+ ##
7
+ # Executes an SSH session with a pfSense host.
8
+ #
9
+ # Valid options:
10
+ # +host+::
11
+ # The name or IP address of the host to connect to. Defaults to 'localhost'.
12
+ # +port+::
13
+ # The port on the host to connect to. Defaults to 22.
14
+ # +user+::
15
+ # The user to login with. This option is required.
16
+ # +password+::
17
+ # The password to login with.
18
+ # If our public key is an authorized key on the host, the password is ignored.
19
+ # +silence_timeout+::
20
+ # When a command is executing, this is the maximum amount of time to wait for any feedback from the shell.
21
+ # If set to 0 (or less) there is no timeout.
22
+ # Unlike +command_timeout+ this value resets every time we receive feedback.
23
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
24
+ # +command_timeout+::
25
+ # When a command is executing, this is the maximum amount of time to wait for the command to finish.
26
+ # If set to 0 (or less) there is no timeout.
27
+ # Unlike +silence_timeout+ this value does not reset when we receive feedback.
28
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
29
+ # +connect_timeout+::
30
+ # This is the maximum amount of time to wait for the initial connection to the SSH shell.
31
+ #
32
+ # Shells::PfSenseSshSession.new(
33
+ # host: '10.10.10.10',
34
+ # user: 'somebody',
35
+ # password: 'super-secret'
36
+ # ) do |shell|
37
+ # cfg = shell.get_config_section("aliases")
38
+ # cfg["alias"] ||= []
39
+ # cfg["alias"] << {
40
+ # :name => "MY_NETWORK",
41
+ # :type => "network",
42
+ # :address => "192.168.1.0/24",
43
+ # :descr => "My home network",
44
+ # :details => "Created #{Time.now.to_s}"
45
+ # }
46
+ # shell.set_config_section("aliases", cfg, "Add home network")
47
+ # shell.apply_filter_config
48
+ # end
49
+ #
50
+ class PfSenseSshSession < SshSession
51
+
52
+ include PfSenseCommon
53
+
54
+ end
55
+
56
+ end
@@ -0,0 +1,184 @@
1
+ require 'rubyserial'
2
+ require 'shells/shell_base'
3
+ require 'shells/bash_common'
4
+
5
+ module Shells
6
+ ##
7
+ # Executes a serial session with a device.
8
+ #
9
+ # The default setup of this class should work well with any bash-like shell.
10
+ # In particular, the +exec_prompt+ method sets the "PS1" environment variable, which should set the prompt the shell
11
+ # uses, and the +get_exit_code+ methods retrieves the value of the "$?" variable which should contain the exit code
12
+ # from the last action. Because there is a possibility that your shell does not utilize those methods, the
13
+ # +override_set_prompt+ and +override_get_exit_code+ options are available to change the behavior.
14
+ #
15
+ #
16
+ # Valid options:
17
+ # +path+::
18
+ # The path to the serial device (e.g. - COM3 or /dev/tty2)
19
+ # This is a required option.
20
+ # +speed+::
21
+ # The bitrate for the connection.
22
+ # The default is 115200.
23
+ # +data_bits+::
24
+ # The number of data bits for the connection.
25
+ # The default is 8.
26
+ # +parity+::
27
+ # The parity for the connection.
28
+ # The default is :none.
29
+ # +prompt+::
30
+ # The prompt used to determine when processes finish execution.
31
+ # Defaults to '~~#', but if that doesn't work for some reason because it is valid output from one or more
32
+ # commands, you can change it to something else. It must be unique and cannot contain certain characters.
33
+ # The characters you should avoid are !, $, \, /, ", and ' because no attempt is made to escape them and the
34
+ # resulting prompt can very easily become something else entirely. If they are provided, they will be
35
+ # replaced to protect the shell from getting stuck.
36
+ # +quit+::
37
+ # If set, this defines the command to execute when quitting the session.
38
+ # The default is "exit" which will probably work most of the time.
39
+ # +retrieve_exit_code+::
40
+ # If set to a non-false value, then the default behavior will be to retrieve the exit code from the shell after
41
+ # executing a command. If set to a false or nil value, the default behavior will be to ignore the exit code
42
+ # from the shell. When retrieved, the exit code is stored in the +last_exit_code+ property.
43
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
44
+ # +on_non_zero_exit_code+::
45
+ # If set to :ignore (the default) then non-zero exit codes will not cause errors. You will still be able to check
46
+ # the +last_exit_code+ property to determine if the command was successful.
47
+ # If set to :raise then non-zero exit codes will cause a Shells::NonZeroExitCode to be raised when a command exits
48
+ # with a non-zero return value.
49
+ # This option only comes into play when +retrieve_exit_code+ is set to a non-false value.
50
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
51
+ # +silence_timeout+::
52
+ # When a command is executing, this is the maximum amount of time to wait for any feedback from the shell.
53
+ # If set to 0 (or less) there is no timeout.
54
+ # Unlike +command_timeout+ this value resets every time we receive feedback.
55
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
56
+ # +command_timeout+::
57
+ # When a command is executing, this is the maximum amount of time to wait for the command to finish.
58
+ # If set to 0 (or less) there is no timeout.
59
+ # Unlike +silence_timeout+ this value does not reset when we receive feedback.
60
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
61
+ # +override_set_prompt+::
62
+ # If provided, this must be set to either a command string that will set the prompt, or a Proc that accepts
63
+ # the shell as an argument.
64
+ # If set to a string, the string is sent to the shell and we wait up to two seconds for the prompt to appear.
65
+ # If that fails, we resend the string and wait one more time before failing.
66
+ # If set to a Proc, the Proc is called. If the Proc returns a false value, we fail. If the Proc returns
67
+ # a non-false value, we consider it successful.
68
+ # +override_get_exit_code+::
69
+ # If provided, this must be set to either a command string that will retrieve the exit code, or a Proc that
70
+ # accepts the shell as an argument.
71
+ # If set to a string, the string is sent to the shell and the output is parsed as an integer and used as the exit
72
+ # code.
73
+ # If set to a Proc, the Proc is called and the return value of the proc is used as the exit code.
74
+ #
75
+ # Shells::SerialSession.new(
76
+ # path: '/dev/ttyusb3',
77
+ # speed: 9600
78
+ # ) do |shell|
79
+ # shell.exec('cd /usr/local/bin')
80
+ # user_bin_files = shell.exec('ls -A1').split("\n")
81
+ # @app_is_installed = user_bin_files.include?('my_app')
82
+ # end
83
+ #
84
+ class SerialSession < Shells::ShellBase
85
+
86
+ include BashCommon
87
+
88
+ ##
89
+ # Sets the line ending for the instance.
90
+ def line_ending=(value)
91
+ @line_ending = value || "\r\n"
92
+ end
93
+
94
+ ##
95
+ # Gets the line ending for the instance.
96
+ def line_ending
97
+ @line_ending ||= "\r\n"
98
+ end
99
+
100
+ protected
101
+
102
+ def validate_options #:nodoc:
103
+ options[:speed] ||= 115200
104
+ options[:data_bits] ||= 8
105
+ options[:parity] ||= :none
106
+ options[:quit] ||= 'exit'
107
+ options[:connect_timeout] ||= 5
108
+
109
+ raise InvalidOption, 'Missing path.' if options[:path].to_s.strip == ''
110
+ end
111
+
112
+ def exec_shell(&block) #:nodoc:
113
+
114
+ debug 'Opening serial port...'
115
+ @serport = Serial.new options[:path], options[:speed], options[:data_bits], options[:parity]
116
+
117
+ begin
118
+ # start buffering
119
+ buffer_input
120
+
121
+ # yield to the block
122
+ block.call
123
+
124
+ ensure
125
+ # send the quit message.
126
+ send_data options[:quit] + line_ending
127
+
128
+ debug 'Closing serial port...'
129
+ @serport.close
130
+ @serport = nil
131
+ end
132
+ end
133
+
134
+ def exec_prompt(&block) #:nodoc:
135
+ cmd = options[:override_set_prompt] || "PS1=\"#{options[:prompt]}\""
136
+ if cmd.respond_to?(:call)
137
+ raise Shells::FailedToSetPrompt unless cmd.call(self)
138
+ else
139
+ # set the prompt, wait up to 2 seconds for a response, then try one more time.
140
+ begin
141
+ exec cmd, command_timeout: 2, retrieve_exit_code: false
142
+ rescue Shells::CommandTimeout
143
+ begin
144
+ exec cmd, command_timeout: 2, retrieve_exit_code: false
145
+ rescue Shells::CommandTimeout
146
+ raise Shells::FailedToSetPrompt
147
+ end
148
+ end
149
+ end
150
+
151
+ # yield to the block
152
+ block.call
153
+ end
154
+
155
+ def send_data(data) #:nodoc:
156
+ @serport.write data
157
+ debug "Sent: (#{data.size} bytes) #{(data.size > 32 ? (data[0..30] + '...') : data).inspect}"
158
+ end
159
+
160
+ def loop(&block) #:nodoc:
161
+ while true
162
+ while true
163
+ data = ''
164
+ while (byte = @serport.getbyte)
165
+ data << byte.chr
166
+ end
167
+ break if data == ""
168
+ debug "Received: (#{data.size} bytes) #{(data.size > 32 ? (data[0..30] + '...') : data).inspect}"
169
+ @_stdout_recv.call data
170
+ end
171
+ break unless block&.call
172
+ end
173
+ end
174
+
175
+ def stdout_received(&block) #:nodoc:
176
+ @_stdout_recv = block
177
+ end
178
+
179
+ def stderr_received(&block) #:nodoc:
180
+ @_stderr_recv = block
181
+ end
182
+
183
+ end
184
+ end