shells 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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