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