shells 0.1.23 → 0.2.0

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,270 @@
1
+ module Shells
2
+
3
+ ##
4
+ # A wrapper class around a base shell to execute pfSense PHP commands within.
5
+ class PfShellWrapper
6
+
7
+ ##
8
+ # The pfSense shell itself.
9
+ PF_SHELL = '/usr/local/sbin/pfSsh.php'
10
+
11
+ ##
12
+ # The prompt in the pfSense shell.
13
+ PF_PROMPT = 'pfSense shell:'
14
+
15
+
16
+ attr_accessor :config_parsed
17
+ private :config_parsed, :config_parsed=
18
+
19
+ attr_accessor :shell
20
+ private :shell, :shell=
21
+
22
+ ##
23
+ # Gets the output from the pfSense PHP shell session.
24
+ attr_accessor :output
25
+
26
+ ##
27
+ # Creates the wrapper, executing the pfSense shell.
28
+ #
29
+ # The provided code block is yielded this wrapper for execution.
30
+ def initialize(base_shell, &block)
31
+ raise ArgumentError, 'a code block is required' unless block_given?
32
+ raise ArgumentError, 'the base shell must be a valid shell' unless base_shell.is_a?(::Shells::ShellBase)
33
+
34
+ self.shell = base_shell
35
+
36
+ wrapper = self
37
+ code_block = block
38
+ self.output = ''
39
+ self.config_parsed = false
40
+
41
+ shell.instance_eval do
42
+ merge_local_buffer do
43
+ begin
44
+ temporary_prompt(PF_PROMPT) do
45
+ debug 'Initializing the pfSense PHP shell...'
46
+ queue_input PF_SHELL + line_ending
47
+ wait_for_prompt 999, 10, true
48
+
49
+ debug ' > initialized'
50
+ begin
51
+ code_block.call wrapper
52
+ ensure
53
+ debug 'Exiting the pfSense PHP shell...'
54
+ if wait_for_prompt(5, 5, false)
55
+ # only queue the exit command if we are still in the pfSense shell.
56
+ queue_input 'exit' + line_ending
57
+ end
58
+ end
59
+ end
60
+ ensure
61
+ # wait for the normal shell to return.
62
+ wait_for_prompt 10, 10, true
63
+ debug ' > exited'
64
+ wrapper.output = output
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ ##
71
+ # Executes a series of commands on the pfSense shell.
72
+ def exec(*commands)
73
+ ret = ''
74
+ commands.each { |cmd| ret += shell.exec(cmd) }
75
+ ret + shell.exec('exec')
76
+ end
77
+
78
+ ##
79
+ # Reloads the pfSense configuration on the device.
80
+ def parse_config
81
+ exec 'parse_config(true);'
82
+ self.config_parsed = true
83
+ end
84
+
85
+ ##
86
+ # Determines if the configuration has been parsed during this session.
87
+ def config_parsed?
88
+ config_parsed
89
+ end
90
+
91
+ ##
92
+ # Gets a configuration section from the pfSense device.
93
+ def get_config_section(section_name)
94
+ parse_config unless config_parsed?
95
+ JSON.parse exec("echo json_encode($config[#{section_name.to_s.inspect}]);").strip
96
+ end
97
+
98
+ ##
99
+ # Sets a configuration section to the pfSense device.
100
+ #
101
+ # Returns the number of changes made to the configuration.
102
+ def set_config_section(section_name, values, message = '')
103
+ current_values = get_config_section(section_name)
104
+ changes = generate_config_changes("$config[#{section_name.to_s.inspect}]", current_values, values)
105
+ if changes&.any?
106
+ if message.to_s.strip == ''
107
+ message = "Updating #{section_name} section."
108
+ end
109
+ changes << "write_config(#{message.inspect});"
110
+
111
+ exec(*changes)
112
+
113
+ (changes.size - 1)
114
+ else
115
+ 0
116
+ end
117
+ end
118
+
119
+ ##
120
+ # Apply the firewall configuration.
121
+ #
122
+ # You need to apply the firewall configuration after you make changes to aliases, NAT rules, or filter rules.
123
+ def apply_filter_config
124
+ exec(
125
+ 'require_once("shaper.inc");',
126
+ 'require_once("filter.inc");',
127
+ 'filter_configure_sync();'
128
+ )
129
+ end
130
+
131
+ ##
132
+ # Applies the user configuration for the specified user.
133
+ def apply_user_config(user_id)
134
+ user_id = user_id.to_i
135
+ exec(
136
+ 'require_once("auth.inc");',
137
+ "$user_entry = $config[\"system\"][\"user\"][#{user_id}];",
138
+ '$user_groups = array();',
139
+ 'foreach ($config["system"]["group"] as $gidx => $group) {',
140
+ ' if (is_array($group["member"])) {',
141
+ " if (in_array(#{user_id}, $group[\"member\"])) { $user_groups[] = $group[\"name\"]; }",
142
+ ' }',
143
+ '}',
144
+ # Intentionally run set_groups before and after to ensure group membership gets fully applied.
145
+ 'local_user_set_groups($user_entry, $user_groups);',
146
+ 'local_user_set($user_entry);',
147
+ 'local_user_set_groups($user_entry, $user_groups);'
148
+ )
149
+ end
150
+
151
+ ##
152
+ # Enabled public key authentication for the current pfSense user.
153
+ #
154
+ # Once this has been done you should be able to connect without using a password.
155
+ def enable_cert_auth(public_key = '~/.ssh/id_rsa.pub')
156
+ cert_regex = /^ssh-[rd]sa (?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)? \S*$/m
157
+
158
+ # get our cert unless the user provided a full cert for us.
159
+ unless public_key =~ cert_regex
160
+ public_key = File.expand_path(public_key)
161
+ if File.exist?(public_key)
162
+ public_key = File.read(public_key).to_s.strip
163
+ else
164
+ raise Shells::PfSenseCommon::PublicKeyNotFound
165
+ end
166
+ raise Shells::PfSenseCommon::PublicKeyInvalid unless public_key =~ cert_regex
167
+ end
168
+
169
+ cfg = get_config_section 'system'
170
+ user_id = nil
171
+ user_name = options[:user].downcase
172
+ cfg['user'].each_with_index do |user,index|
173
+ if user['name'].downcase == user_name
174
+ user_id = index
175
+
176
+ authkeys = Base64.decode64(user['authorizedkeys'].to_s).gsub("\r\n", "\n").strip
177
+ unless authkeys == '' || authkeys =~ cert_regex
178
+ warn "Existing authorized keys for user #{options[:user]} are invalid and are being reset."
179
+ authkeys = ''
180
+ end
181
+
182
+ if authkeys == ''
183
+ user['authorizedkeys'] = Base64.strict_encode64(public_key)
184
+ else
185
+ authkeys = authkeys.split("\n")
186
+ unless authkeys.include?(public_key)
187
+ authkeys << public_key unless authkeys.include?(public_key)
188
+ user['authorizedkeys'] = Base64.strict_encode64(authkeys.join("\n"))
189
+ end
190
+ end
191
+
192
+ break
193
+ end
194
+ end
195
+
196
+
197
+ raise Shells::PfSenseCommon::UserNotFound unless user_id
198
+
199
+ set_config_section 'system', cfg, "Enable certificate authentication for #{options[:user]}."
200
+
201
+ apply_user_config user_id
202
+ end
203
+
204
+
205
+ ##
206
+ # Exits the shell session immediately and requests a reboot of the pfSense device.
207
+ def reboot
208
+ raise Shells::NotRunning unless running?
209
+ raise Shells::PfSenseCommon::RestartNow
210
+ end
211
+
212
+ ##
213
+ # Exits the shell session immediately.
214
+ def quit
215
+ raise Shells::NotRunning unless running?
216
+ raise Shells::ShellBase::QuitNow
217
+ end
218
+
219
+ private
220
+
221
+ def generate_config_changes(prefix, old_value, new_value)
222
+ old_value = fix_config_arrays(old_value)
223
+ new_value = fix_config_arrays(new_value)
224
+
225
+ if new_value.is_a?(Hash)
226
+ changes = []
227
+
228
+ unless old_value.is_a?(Hash)
229
+ # make sure the value is an array now.
230
+ changes << "#{prefix} = array();"
231
+ # and change the old_value to be an empty hash so we can work with it.
232
+ old_value = {}
233
+ end
234
+
235
+ # now iterate the hashes and process the child elements.
236
+ new_value.each do |k, new_v|
237
+ old_v = old_value[k]
238
+ changes += generate_config_changes("#{prefix}[#{k.inspect}]", old_v, new_v)
239
+ end
240
+
241
+ changes
242
+ else
243
+ if new_value != old_value
244
+ if new_value.nil?
245
+ [ "unset #{prefix};" ]
246
+ else
247
+ [ "#{prefix} = #{new_value.inspect};" ]
248
+ end
249
+ else
250
+ [ ]
251
+ end
252
+ end
253
+ end
254
+
255
+ def fix_config_arrays(value)
256
+ if value.is_a?(Array)
257
+ value.each_with_index
258
+ .map{|v,i| [i,v]}.to_h # convert to hash
259
+ .inject({}){ |m,(k,v)| m[k.to_s] = v; m } # stringify keys
260
+ elsif value.is_a?(Hash)
261
+ value.inject({}) { |m,(k,v)| m[k.to_s] = v; m } # stringify keys
262
+ else
263
+ value
264
+ end
265
+ end
266
+
267
+ end
268
+
269
+
270
+ end
@@ -0,0 +1,65 @@
1
+ require 'shells/serial_shell'
2
+ require 'shells/bash_common'
3
+
4
+ module Shells
5
+ ##
6
+ # Executes a serial session with a device.
7
+ #
8
+ # The default setup of this class should work well with any bash-like shell.
9
+ #
10
+ # Valid options:
11
+ # +path+::
12
+ # The path to the serial device (e.g. - COM3 or /dev/tty2)
13
+ # This is a required option.
14
+ # +speed+::
15
+ # The bitrate for the connection.
16
+ # The default is 115200.
17
+ # +data_bits+::
18
+ # The number of data bits for the connection.
19
+ # The default is 8.
20
+ # +parity+::
21
+ # The parity for the connection.
22
+ # The default is :none.
23
+ # +prompt+::
24
+ # The prompt used to determine when processes finish execution.
25
+ # +quit+::
26
+ # If set, this defines the command to execute when quitting the session.
27
+ # The default is "exit" which will probably work most of the time.
28
+ # +retrieve_exit_code+::
29
+ # If set to a non-false value, then the default behavior will be to retrieve the exit code from the shell after
30
+ # executing a command. If set to a false or nil value, the default behavior will be to ignore the exit code
31
+ # from the shell. When retrieved, the exit code is stored in the +last_exit_code+ property.
32
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
33
+ # +on_non_zero_exit_code+::
34
+ # If set to :ignore (the default) then non-zero exit codes will not cause errors. You will still be able to check
35
+ # the +last_exit_code+ property to determine if the command was successful.
36
+ # If set to :raise then non-zero exit codes will cause a Shells::NonZeroExitCode to be raised when a command exits
37
+ # with a non-zero return value.
38
+ # This option only comes into play when +retrieve_exit_code+ is set to a non-false value.
39
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
40
+ # +silence_timeout+::
41
+ # When a command is executing, this is the maximum amount of time to wait for any feedback from the shell.
42
+ # If set to 0 (or less) there is no timeout.
43
+ # Unlike +command_timeout+ this value resets every time we receive feedback.
44
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
45
+ # +command_timeout+::
46
+ # When a command is executing, this is the maximum amount of time to wait for the command to finish.
47
+ # If set to 0 (or less) there is no timeout.
48
+ # Unlike +silence_timeout+ this value does not reset when we receive feedback.
49
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
50
+ #
51
+ # Shells::SerialBashShell.new(
52
+ # path: '/dev/ttyusb3',
53
+ # speed: 9600
54
+ # ) do |shell|
55
+ # shell.exec('cd /usr/local/bin')
56
+ # user_bin_files = shell.exec('ls -A1').split("\n")
57
+ # @app_is_installed = user_bin_files.include?('my_app')
58
+ # end
59
+ #
60
+ class SerialBashShell < SerialShell
61
+
62
+ include Shells::BashCommon
63
+
64
+ end
65
+ end
@@ -1,4 +1,4 @@
1
- require 'shells/serial_session'
1
+ require 'shells/serial_bash_shell'
2
2
  require 'shells/pf_sense_common'
3
3
 
4
4
  module Shells
@@ -30,23 +30,25 @@ module Shells
30
30
  # Unlike +silence_timeout+ this value does not reset when we receive feedback.
31
31
  # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
32
32
  #
33
- # Shells::PfSenseSerialSession.new(
33
+ # Shells::SerialPfSenseShell.new(
34
34
  # path: 'COM3'
35
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
36
+ # shell.pf_shell 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
47
49
  # end
48
50
  #
49
- class PfSenseSerialSession < SerialSession
51
+ class SerialPfSenseShell < SerialShell
50
52
 
51
53
  include PfSenseCommon
52
54
 
@@ -1,18 +1,10 @@
1
1
  require 'rubyserial'
2
2
  require 'shells/shell_base'
3
- require 'shells/bash_common'
4
3
 
5
4
  module Shells
6
5
  ##
7
6
  # Executes a serial session with a device.
8
7
  #
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
8
  # Valid options:
17
9
  # +path+::
18
10
  # The path to the serial device (e.g. - COM3 or /dev/tty2)
@@ -28,11 +20,6 @@ module Shells
28
20
  # The default is :none.
29
21
  # +prompt+::
30
22
  # 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
23
  # +quit+::
37
24
  # If set, this defines the command to execute when quitting the session.
38
25
  # The default is "exit" which will probably work most of the time.
@@ -58,21 +45,8 @@ module Shells
58
45
  # If set to 0 (or less) there is no timeout.
59
46
  # Unlike +silence_timeout+ this value does not reset when we receive feedback.
60
47
  # 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
48
  #
75
- # Shells::SerialSession.new(
49
+ # Shells::SerialShell.new(
76
50
  # path: '/dev/ttyusb3',
77
51
  # speed: 9600
78
52
  # ) do |shell|
@@ -81,9 +55,30 @@ module Shells
81
55
  # @app_is_installed = user_bin_files.include?('my_app')
82
56
  # end
83
57
  #
84
- class SerialSession < Shells::ShellBase
58
+ class SerialShell < Shells::ShellBase
59
+
60
+ attr_accessor :serport
61
+ private :serport, :serport=
62
+
63
+ attr_accessor :ser_stdout_recv
64
+ private :ser_stdout_recv, :ser_stdout_recv=
65
+
66
+ attr_accessor :output_reader
67
+ private :output_reader, :output_reader=
68
+
69
+ add_hook :on_before_run do |sh|
70
+ sh.instance_eval do
71
+ self.serport = nil
72
+ self.output_reader = nil
73
+ end
74
+ end
85
75
 
86
- include BashCommon
76
+ add_hook :on_after_run do |sh|
77
+ sh.instance_eval do
78
+ self.serport = nil
79
+ self.output_reader = nil
80
+ end
81
+ end
87
82
 
88
83
  ##
89
84
  # Sets the line ending for the instance.
@@ -109,75 +104,68 @@ module Shells
109
104
  raise InvalidOption, 'Missing path.' if options[:path].to_s.strip == ''
110
105
  end
111
106
 
112
- def exec_shell(&block) #:nodoc:
113
-
107
+ def connect #:nodoc:
114
108
  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
109
+ self.serport = Serial.new(options[:path], options[:speed], options[:data_bits], options[:parity])
110
+ debug 'Starting output reading thread...'
111
+ self.output_reader = Thread.start(self) do |shell|
112
+ while true
113
+ shell.instance_eval do
114
+ data = ''
115
+ while (byte = serport&.getbyte)
116
+ data << byte.chr
117
+ end
118
+ if data != ''
119
+ # add to the output buffer.
120
+ debug "Received: (#{data.size} bytes) #{(data.size > 32 ? (data[0..30] + '...') : data).inspect}"
121
+ ser_stdout_recv&.call data
122
+ end
123
+ end
124
+ Thread.pass
125
+ end
126
+ end
123
127
 
124
- ensure
125
- # send the quit message.
126
- send_data options[:quit] + line_ending
128
+ end
127
129
 
128
- debug 'Closing serial port...'
129
- @serport.close
130
- @serport = nil
131
- end
130
+ def disconnect #:nodoc:
131
+ output_reader&.exit
132
+ serport.close
132
133
  end
133
134
 
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, command_is_echoed: false
142
- rescue Shells::CommandTimeout
143
- begin
144
- exec cmd, command_timeout: 2, retrieve_exit_code: false, command_is_echoed: false
145
- rescue Shells::CommandTimeout
146
- raise Shells::FailedToSetPrompt
147
- end
148
- end
149
- end
135
+ def setup
136
+ # send a newline to the shell to (hopefully) redraw a menu.
137
+ debug 'Refreshing...'
138
+ queue_input line_ending
150
139
 
151
- # yield to the block
152
- block.call
140
+ debug 'Calling setup_prompt...'
141
+ setup_prompt
142
+ debug ' > prompt setup'
153
143
  end
154
144
 
155
145
  def send_data(data) #:nodoc:
156
- @serport.write data
146
+ serport.write data
157
147
  debug "Sent: (#{data.size} bytes) #{(data.size > 32 ? (data[0..30] + '...') : data).inspect}"
158
148
  end
159
149
 
160
- def loop(&block) #:nodoc:
150
+ def active?
151
+ return false if serport.nil?
152
+ return false if serport.closed?
153
+ true
154
+ end
155
+
156
+ def io_loop(&block) #:nodoc:
161
157
  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
158
+ break unless block.call
159
+ Thread.pass
172
160
  end
173
161
  end
174
162
 
175
163
  def stdout_received(&block) #:nodoc:
176
- @_stdout_recv = block
164
+ sync { self.ser_stdout_recv = block }
177
165
  end
178
166
 
179
167
  def stderr_received(&block) #:nodoc:
180
- @_stderr_recv = block
168
+ nil # no stderr to report.
181
169
  end
182
170
 
183
171
  end