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.
- 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
|