shells 0.1.23 → 0.2.0

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