shells 0.1.23 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 79a5181a0a44f0f7f3f58fa2d8016e882e1a0a85
4
- data.tar.gz: 93cac3a6699467509be0e4165fa21c91b727d77e
3
+ metadata.gz: 8761b43321663a64a0ff144de42cb5487e1bea10
4
+ data.tar.gz: 824f68405ca177cae6550bc2a54dc8c05abaec01
5
5
  SHA512:
6
- metadata.gz: fd579203b87620a7d14bf40a31dbbeb5e4d4c66f13ac8b0e93fdca5b19de39c67ac63c0054359a03ed26a740aede2e624604ed017ab5270a5fdc5859098cecc9
7
- data.tar.gz: 40a853476c627024cb33386434f7b3d612078ada71fd81f80d7287f767812e1e9a32daf5bba58f1d92c4663a69c90fff87f71c76c69eef4eb50e69b17ba8af85
6
+ metadata.gz: cccddac76cfd89fa71946057947f35456fe14f8158323c83534d14692b724315a9d1dddfa0c154c6360081d57d9c97ab753dbba1f2b04d51a5f7bab3c639304c
7
+ data.tar.gz: 7f09ec1b89c15ef347733bd00bdcdb09501d94fd793c2656547f028f77fd27524fb6ee1cd4fccc6f486b3b188f7543f08c34bb0bfd6dcff3903539ee20e3695b
data/README.md CHANGED
@@ -5,7 +5,9 @@ It started as a secure shell to interact with SSH hosts, then it received a shel
5
5
  A natural progression had me adding a serial shell and another shell to access pfSense devices over serial.
6
6
 
7
7
  If you can't tell, it was primarily developed to interact with pfSense devices, but also tends to work well
8
- for interacting with other devices and hosts as well.
8
+ for interacting with other devices and hosts as well. With version 0.2, some changes were made to work
9
+ better with all sorts of shells connections. Version 0.2 is not compatible with code written for version
10
+ 0.1.
9
11
 
10
12
 
11
13
  ## Installation
@@ -27,29 +29,25 @@ Or install it yourself as:
27
29
 
28
30
  ## Usage
29
31
 
30
- Any of the various "Session" classes can be used to interact with a device or host.
32
+ Any of the various "Shell" classes can be used to interact with a device or host.
31
33
 
32
34
  ```ruby
33
- Shells::SshSession.new(host: 'my.device.name.or.ip', user: 'somebody', password: 'secret') do |sh|
35
+ shell = Shells::SshBashShell.new(host: 'my.device.name.or.ip', user: 'somebody', password: 'secret')
36
+ shell.run do |sh|
34
37
  sh.exec "cd /usr/local/bin"
35
38
  user_bin_files = sh.exec("ls -A1").split("\n")
36
39
  @app_is_installed = user_bin_files.include?("my_app")
37
40
  end
38
41
  ```
39
42
 
40
- Every session constructor works the same way. The shell is connected to, the prompt is set, and then the block
41
- of code passed to the constructor is executed on the shell. After the code block completes, the session is finalized
42
- and the shell is closed.
43
-
44
- The `Shells` module is designed to allow you to forego the `.new` as well. So `Shells::SshSession(...)` is the same as
45
- `Shells::SshSession.new(...)`.
43
+ If you provide a block to `.new`, it will be run automatically after initializing the shell.
46
44
 
47
45
  In most cases you will be sending commands to the shell using the `.exec` method of the shell passed to the code block.
48
46
  The `.exec` method returns the output of the command and then you can process the results. You can also request that
49
47
  the `.exec` method retrieves the exit code from the command as well, but this will only work in some shells.
50
48
 
51
49
  ```ruby
52
- Shells::SshSession(host: 'my.device.name.or.ip', user: 'somebody', password: 'secret') do |sh|
50
+ Shells::SshBashShell.new(host: 'my.device.name.or.ip', user: 'somebody', password: 'secret') do |sh|
53
51
  # By default shells do not retrieve exit codes or raise on non-zero exit codes.
54
52
  # These parameters can be set in the options list for the constructor as well to change the
55
53
  # default behavior.
@@ -7,8 +7,6 @@ require "shells"
7
7
  # with your gem easier. You can also use a different console, if you like.
8
8
 
9
9
  # (If you use this, don't forget to add pry to your Gemfile!)
10
- require "pry"
10
+ require 'rb-readline'
11
+ require 'pry'
11
12
  Pry.start
12
-
13
- # require "irb"
14
- # IRB.start(__FILE__)
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "net/ssh"
5
+ require "io/console"
6
+ require "thread"
7
+
8
+ class TestClient
9
+ LINE_SEPARATOR = ('=-' * 39) + "=\n\n"
10
+
11
+ attr_accessor :host, :port, :user, :password
12
+
13
+ def initialize(options = {})
14
+ self.host = options[:host]
15
+ self.port = options[:port]
16
+ self.user = options[:user]
17
+ self.password = options[:password]
18
+ end
19
+
20
+
21
+ def run
22
+ show_header
23
+ input_config
24
+ run_session
25
+ show_footer
26
+ end
27
+
28
+
29
+ def show_header
30
+ STDOUT.print "Simple SSH Test Client\n"
31
+ STDOUT.print LINE_SEPARATOR
32
+ end
33
+
34
+
35
+ def show_footer
36
+ STDOUT.print LINE_SEPARATOR.strip
37
+ STDOUT.print "\nTest client exiting successfully.\nSent #{@bytes_out} bytes, received #{@bytes_in} bytes.\n\n"
38
+ end
39
+
40
+
41
+ def input_config
42
+ STDOUT.print 'Enter host name: '
43
+ self.host = STDIN.gets.to_s.strip
44
+ raise StandardError, 'Host cannot be blank.' if host == ''
45
+
46
+ STDOUT.print 'Enter port number: '
47
+ self.port = STDIN.gets.to_s.strip.to_i
48
+ self.port = 22 if port == 0
49
+ raise StandardError, 'Port must be between 1 and 65535.' unless (1..65535).include?(port)
50
+
51
+ STDOUT.print 'Enter user name: '
52
+ self.user = STDIN.gets.to_s.strip
53
+ raise StandardError, 'User cannot be blank.' if user == ''
54
+
55
+ STDOUT.print 'Enter password: '
56
+ self.password = STDIN.noecho(&:gets).strip
57
+ STDOUT.print "\n"
58
+
59
+ STDOUT.print LINE_SEPARATOR
60
+ end
61
+
62
+
63
+ def run_session
64
+ validate_config
65
+ reset_for_session
66
+
67
+ begin
68
+ STDOUT.print 'Connecting ... '
69
+ Net::SSH.start(host, user, port: port, password: password) do |ssh|
70
+ STDOUT.print "Connected\n"
71
+
72
+ # open a channel
73
+ channel = ssh.open_channel do |ch|
74
+
75
+ setup_receive_handlers_for ch
76
+
77
+ # get a PTY.
78
+ ch.request_pty do |_, pty_success|
79
+ raise StandardError, 'Failed to request PTY.' unless pty_success
80
+ end
81
+
82
+ # start an interactive shell.
83
+ ch.send_channel_request('shell') do |_, shell_success|
84
+ raise StandardError, 'Failed to start shell.' unless shell_success
85
+ end
86
+ end
87
+
88
+ # so the channel is now open, so we'll interact with it until it closes.
89
+
90
+ # buffer key input from stdin.
91
+ buffer_stdin do
92
+
93
+ # spend up to 1ms waiting for IO events each pass
94
+ ssh.loop(0.001) do |sh|
95
+
96
+ # if the channel is still active, try interacting with it.
97
+ if channel.active?
98
+ begin
99
+
100
+ # try getting the oldest key from the key buffer.
101
+ ch = key_from_stdin
102
+
103
+ if ch
104
+ # if we have a key, send it to the shell.
105
+ channel.send_data ch
106
+ @bytes_out += ch.bytes.length
107
+ end
108
+
109
+ # the channel is active so return true to keep the session going.
110
+ true
111
+ rescue IOError
112
+ # any IO error will kill our session.
113
+ false
114
+ end
115
+ else
116
+ # the channel is no longer active so our session is done.
117
+ false
118
+ end
119
+ end
120
+
121
+ end
122
+ end
123
+ ensure
124
+
125
+ # make sure we enable echo before we exit the session.
126
+ STDIN.echo = true
127
+ end
128
+ end
129
+
130
+
131
+ private
132
+
133
+
134
+ def validate_config
135
+ raise StandardError, 'Host cannot be blank.' if host == ''
136
+ raise StandardError, 'Port must be between 1 and 65535.' unless (1..65535).include?(port)
137
+ raise StandardError, 'User cannot be blank.' if user == ''
138
+ end
139
+
140
+ def reset_for_session
141
+ @bytes_in = 0
142
+ @bytes_out = 0
143
+ end
144
+
145
+ def setup_receive_handlers_for(ch)
146
+
147
+ ch.on_data do |_, data|
148
+ @bytes_in += data.bytes.length
149
+ STDOUT.print data
150
+ end
151
+
152
+ ch.on_extended_data do |_, _, data|
153
+ @bytes_in += data.bytes.length
154
+ STDERR.print data
155
+ end
156
+
157
+ end
158
+
159
+ def buffer_stdin
160
+
161
+ @key_buf = []
162
+ @key_mutex = Mutex.new
163
+
164
+ key_reader = Thread.new do
165
+ loop do
166
+
167
+ # read a key
168
+ ch = STDIN.getch
169
+
170
+ # push it onto the buffer
171
+ @key_mutex.synchronize { @key_buf&.push ch }
172
+
173
+ # run forever
174
+ true
175
+ end
176
+ end
177
+
178
+ begin
179
+ # disable echo and yield to the block.
180
+ STDIN.echo = false
181
+ yield
182
+ ensure
183
+ # enable echo, kill the thread, and nullify the buffer.
184
+ STDIN.echo = true
185
+ key_reader.exit
186
+ @key_buf = nil
187
+ @key_mutex = nil
188
+ end
189
+ end
190
+
191
+ def key_from_stdin
192
+ return nil unless @key_mutex
193
+ @key_mutex.synchronize { @key_buf&.shift }
194
+ end
195
+
196
+ end
197
+
198
+
199
+ if $0 == __FILE__
200
+ TestClient.new.run
201
+ end
202
+
@@ -1,21 +1,17 @@
1
1
  require 'shells/version'
2
2
  require 'shells/errors'
3
3
  require 'shells/shell_base'
4
- require 'shells/ssh_session'
5
- require 'shells/serial_session'
6
- require 'shells/pf_sense_ssh_session'
7
- require 'shells/pf_sense_serial_session'
4
+ require 'shells/ssh_shell'
5
+ require 'shells/serial_shell'
6
+ require 'shells/ssh_bash_shell'
7
+ require 'shells/serial_bash_shell'
8
+ require 'shells/ssh_pf_sense_shell'
9
+ require 'shells/serial_pf_sense_shell'
8
10
 
9
11
 
10
12
  ##
11
13
  # A set of basic shell classes.
12
14
  #
13
- # All shell sessions can be accessed by class name without calling +new+.
14
- # Shells::SshSession(host: ...)
15
- # Shells::SerialSession(path: ...)
16
- # Shells::PfSenseSshSession(host: ...)
17
- # Shells::PfSenseSerialSession(path: ...)
18
- #
19
15
  module Shells
20
16
 
21
17
  ##
@@ -82,10 +82,18 @@ module Shells
82
82
  options = (options || {}).merge(retrieve_exit_code: false, on_non_zero_exit_code: :ignore)
83
83
  sudo_exec command, options, &block
84
84
  end
85
-
85
+
86
86
 
87
87
  protected
88
88
 
89
+ ##
90
+ # Uses the PS1= command to set the prompt for the shell.
91
+ def setup_prompt #:nodoc:
92
+ command = "PS1=#{options[:prompt]}"
93
+ sleep 1.0 # let shell initialize fully.
94
+ exec_ignore_code command, silence_timeout: 10, command_timeout: 10, timeout_error: true, get_output: false
95
+ end
96
+
89
97
  ##
90
98
  # Gets an exit code by echoing the $? variable from the environment.
91
99
  #
@@ -97,11 +105,11 @@ module Shells
97
105
  cmd.call(self)
98
106
  else
99
107
  debug 'Retrieving exit code from last command...'
100
- push_buffer
101
- send_data cmd + line_ending
102
- wait_for_prompt 1, 2
103
- ret = command_output(cmd).strip.to_i
104
- pop_discard_buffer
108
+ discard_local_buffer do
109
+ send_data cmd + line_ending
110
+ wait_for_prompt 1, 2
111
+ ret = command_output(cmd).strip.to_i
112
+ end
105
113
  debug 'Exit code: ' + ret.to_s
106
114
  ret
107
115
  end
@@ -119,7 +127,8 @@ module Shells
119
127
  def file_methods
120
128
  @file_methods ||= [
121
129
  :base64,
122
- :openssl
130
+ :openssl,
131
+ :perl
123
132
  ]
124
133
  end
125
134
 
@@ -171,6 +180,7 @@ module Shells
171
180
  exec cmd
172
181
  end
173
182
 
183
+
174
184
  ret = block.call(b64path)
175
185
 
176
186
  exec "rm #{b64path.inspect}"
@@ -1,4 +1,13 @@
1
1
  module Shells
2
+
3
+ ##
4
+ # Raise a QuitNow to tell the shell to stop processing and exit.
5
+ #
6
+ # This is intentionally NOT a RuntimeError since it should only be caught inside the session.
7
+ class QuitNow < Exception
8
+
9
+ end
10
+
2
11
  ##
3
12
  # An error occurring within the SecureShell class aside from argument errors.
4
13
  class ShellError < StandardError
@@ -12,8 +21,14 @@ module Shells
12
21
  end
13
22
 
14
23
  ##
15
- # An error raised when a command requiring a session is attempted after the session has been completed.
16
- class SessionCompleted < ShellError
24
+ # An error raised when +run+ is executed on a shell that is currently running.
25
+ class AlreadyRunning < ShellError
26
+
27
+ end
28
+
29
+ ##
30
+ # An error raised when a method requiring a running shell is called when the shell is not currently running.
31
+ class NotRunning < ShellError
17
32
 
18
33
  end
19
34
 
@@ -35,15 +50,21 @@ module Shells
35
50
  end
36
51
  end
37
52
 
53
+ ##
54
+ # A timeout error raised by the shell.
55
+ class ShellTimeout < ShellError
56
+
57
+ end
58
+
38
59
  ##
39
60
  # An error raised when a session is waiting for output for too long.
40
- class SilenceTimeout < ShellError
61
+ class SilenceTimeout < ShellTimeout
41
62
 
42
63
  end
43
64
 
44
65
  ##
45
66
  # An error raise when a session is waiting for a command to finish for too long.
46
- class CommandTimeout < ShellError
67
+ class CommandTimeout < ShellTimeout
47
68
 
48
69
  end
49
70
 
@@ -1,6 +1,8 @@
1
1
  require 'base64'
2
2
  require 'json'
3
3
 
4
+ require 'shells/pf_shell_wrapper'
5
+
4
6
  module Shells
5
7
 
6
8
  ##
@@ -32,54 +34,70 @@ module Shells
32
34
  end
33
35
 
34
36
  ##
35
- # Signals that we want to restart the device.
36
- class RestartNow < Exception
37
+ # The shell is already in pf_shell mode.
38
+ class AlreadyInPfShell < Shells::ShellError
37
39
 
38
40
  end
39
41
 
42
+ ##
43
+ # The shell not in pf_shell mode.
44
+ class NotInPfShell < Shells::ShellError
45
+
46
+ end
40
47
 
41
48
  ##
42
- # The base shell used when possible.
43
- BASE_SHELL = '/bin/sh'
49
+ # Signals that we want to restart the device.
50
+ class RestartNow < Exception
51
+
52
+ end
53
+
44
54
 
45
55
  ##
46
- # The pfSense shell itself.
47
- PF_SHELL = '/usr/local/sbin/pfSsh.php'
56
+ # The prompt text for the main menu.
57
+ MENU_PROMPT = 'Enter an option:'
48
58
 
49
59
  ##
50
- # The prompt in the pfSense shell.
51
- PF_PROMPT = 'pfSense shell:'
60
+ # The base shell used when possible.
61
+ BASE_SHELL = '/bin/sh'
52
62
 
53
63
  ##
54
64
  # Gets the version of the pfSense firmware.
55
65
  attr_accessor :pf_sense_version
66
+ protected :pf_sense_version=
56
67
 
57
68
  ##
58
69
  # Gets the user currently logged into the pfSense device.
59
70
  attr_accessor :pf_sense_user
71
+ protected :pf_sense_user=
60
72
 
61
73
  ##
62
74
  # Gets the hostname of the pfSense device.
63
75
  attr_accessor :pf_sense_host
76
+ protected :pf_sense_host=
64
77
 
65
78
 
66
- def line_ending #:nodoc:
67
- "\n"
68
- end
69
-
70
79
  def self.included(base) #:nodoc:
71
80
 
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
81
+ base.class_eval do
82
+ # Trap the RestartNow exception.
83
+ # When encountered, change the :quit option to '/sbin/reboot'.
84
+ # This requires rewriting the @options instance variable since the hash is frozen
85
+ # after initial validation.
86
+ on_exception do |shell, ex|
87
+ if ex.is_a?(Shells::PfSenseCommon::RestartNow)
88
+ shell.send(:change_quit, '/sbin/reboot')
89
+ :break
90
+ end
91
+ end
92
+
93
+ add_hook :on_before_run do |sh|
94
+ sh.instance_eval do
95
+ self.pf_sense_version = nil
96
+ self.pf_sense_user = nil
97
+ self.pf_sense_host = nil
98
+ end
82
99
  end
100
+
83
101
  end
84
102
 
85
103
  end
@@ -87,285 +105,81 @@ module Shells
87
105
  def validate_options #:nodoc:
88
106
  super
89
107
  options[:shell] = :shell
90
- options[:prompt] = 'pfSense shell:'
91
108
  options[:quit] = 'exit'
92
109
  options[:retrieve_exit_code] = false
93
110
  options[:on_non_zero_exit_code] = :ignore
94
- options[:override_set_prompt] = ->(sh) { true }
95
- options[:override_get_exit_code] = ->(sh) { 0 }
96
111
  end
97
112
 
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
113
+ def line_ending
114
+ @line_ending ||= "\n"
146
115
  end
147
116
 
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
117
+ def setup_prompt #:nodoc:
168
118
 
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
119
+ # By default we have the main menu.
120
+ # We want to drop to the main shell to execute the PHP shell.
121
+ # So we'll navigate the menu to get the option for the shell.
122
+ # For this first navigation we allow a delay only if we are not connected to a serial device.
123
+ # Serial connections are always on, so they don't need to initialize first.
124
+ menu_option = get_menu_option 'Shell', !(Shells::SerialShell > self.class)
125
+ raise MenuNavigationFailure unless menu_option
175
126
 
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});"
127
+ # For 2.3 and 2.4 this is a valid match.
128
+ # If future versions change the default prompt, we need to change our process.
129
+ # [VERSION][USER@HOSTNAME]/root: where /root is the current dir.
130
+ shell_regex = /\[(?<VER>[^\]]*)\]\[(?<USERHOST>[^\]]*)\](?<CD>\/.*):\s*$/
188
131
 
189
- pf_exec(*changes)
132
+ # Now we execute the menu option and wait for the shell_regex to match.
133
+ temporary_prompt(shell_regex) do
134
+ exec menu_option.to_s, command_timeout: 5
190
135
 
191
- (changes.size - 1)
192
- else
193
- 0
136
+ # Once we have a match we should be able to repeat it and store the information from the shell.
137
+ data = prompt_match.match(output)
138
+ self.pf_sense_version = data['VER']
139
+ self.pf_sense_user, _, self.pf_sense_host = data['USERHOST'].partition('@')
194
140
  end
195
- end
196
141
 
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
- )
142
+ # at this point we can now treat it like a regular tcsh shell.
143
+ command = "set prompt='#{options[:prompt]}'"
144
+ exec_ignore_code command, silence_timeout: 10, command_timeout: 10, timeout_error: true, get_output: false
207
145
  end
208
146
 
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
147
+ def teardown #:nodoc:
148
+ # use the default teardown to exit the shell.
149
+ super
269
150
 
270
- break
271
- end
151
+ # then navigate to the logout option (if the shell is still active).
152
+ if active?
153
+ menu_option = get_menu_option 'Logout'
154
+ raise MenuNavigationFailure unless menu_option
155
+ exec_ignore_code menu_option.to_s, command_timeout: 1, timeout_error: false
272
156
  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
157
  end
281
158
 
282
-
283
159
  ##
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
160
+ # Executes the code block in the pfSense PHP shell.
161
+ def pf_shell(&block)
162
+ ::Shells::PfShellWrapper.new(self, &block).output
288
163
  end
289
164
 
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
165
  private
300
166
 
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
167
  # Processes the pfSense console menu to determine the option to send.
350
168
  def get_menu_option(option_text, delay = true)
351
169
  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
170
 
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
171
+ temporary_prompt MENU_PROMPT do
172
+ # give the prompt a few seconds to draw.
173
+ if delay
174
+ wait_for_prompt(nil, 4, false)
175
+ end
366
176
 
367
- push_buffer
177
+ # See if we have a menu already.
178
+ menu_regex = /(?<MENU>\s0\)(?:.|\r|\n(?!\s0\)))*)#{MENU_PROMPT}[ \t]*$/
179
+ match = menu_regex.match(output)
180
+ menu = match ? match['MENU'] : nil
368
181
 
182
+ discard_local_buffer do
369
183
  if menu.nil?
370
184
  # We want to redraw the menu.
371
185
  # In order to do that, we need to send a command that is not valid.
@@ -389,8 +203,6 @@ module Shells
389
203
  else
390
204
  return nil
391
205
  end
392
- ensure
393
- pop_discard_buffer
394
206
  end
395
207
  end
396
208
  end