shells 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/shells/bash_common.rb +170 -0
- data/lib/shells/errors.rb +57 -0
- data/lib/shells/pf_sense_common.rb +400 -0
- data/lib/shells/pf_sense_serial_session.rb +55 -0
- data/lib/shells/pf_sense_ssh_session.rb +56 -0
- data/lib/shells/serial_session.rb +184 -0
- data/lib/shells/shell_base.rb +846 -0
- data/lib/shells/ssh_session.rb +232 -0
- data/lib/shells/version.rb +5 -0
- data/lib/shells.rb +37 -0
- data/shells.gemspec +32 -0
- metadata +160 -0
@@ -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
|