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 +4 -4
- data/README.md +8 -10
- data/bin/console +2 -4
- data/bin/test-client +202 -0
- data/lib/shells.rb +6 -10
- data/lib/shells/bash_common.rb +17 -7
- data/lib/shells/errors.rb +25 -4
- data/lib/shells/pf_sense_common.rb +85 -273
- data/lib/shells/pf_shell_wrapper.rb +270 -0
- data/lib/shells/serial_bash_shell.rb +65 -0
- data/lib/shells/{pf_sense_serial_session.rb → serial_pf_sense_shell.rb} +16 -14
- data/lib/shells/{serial_session.rb → serial_shell.rb} +66 -78
- data/lib/shells/shell_base.rb +17 -867
- data/lib/shells/shell_base/debug.rb +37 -0
- data/lib/shells/shell_base/exec.rb +175 -0
- data/lib/shells/shell_base/hooks.rb +83 -0
- data/lib/shells/shell_base/input.rb +50 -0
- data/lib/shells/shell_base/interface.rb +149 -0
- data/lib/shells/shell_base/options.rb +111 -0
- data/lib/shells/shell_base/output.rb +217 -0
- data/lib/shells/shell_base/prompt.rb +141 -0
- data/lib/shells/shell_base/regex_escape.rb +23 -0
- data/lib/shells/shell_base/run.rb +188 -0
- data/lib/shells/shell_base/sync.rb +24 -0
- data/lib/shells/ssh_bash_shell.rb +71 -0
- data/lib/shells/{pf_sense_ssh_session.rb → ssh_pf_sense_shell.rb} +16 -14
- data/lib/shells/ssh_shell.rb +215 -0
- data/lib/shells/version.rb +1 -1
- data/shells.gemspec +1 -0
- metadata +35 -6
- data/lib/shells/ssh_session.rb +0 -249
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8761b43321663a64a0ff144de42cb5487e1bea10
|
4
|
+
data.tar.gz: 824f68405ca177cae6550bc2a54dc8c05abaec01
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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 "
|
32
|
+
Any of the various "Shell" classes can be used to interact with a device or host.
|
31
33
|
|
32
34
|
```ruby
|
33
|
-
Shells::
|
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
|
-
|
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::
|
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.
|
data/bin/console
CHANGED
@@ -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
|
10
|
+
require 'rb-readline'
|
11
|
+
require 'pry'
|
11
12
|
Pry.start
|
12
|
-
|
13
|
-
# require "irb"
|
14
|
-
# IRB.start(__FILE__)
|
data/bin/test-client
ADDED
@@ -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
|
+
|
data/lib/shells.rb
CHANGED
@@ -1,21 +1,17 @@
|
|
1
1
|
require 'shells/version'
|
2
2
|
require 'shells/errors'
|
3
3
|
require 'shells/shell_base'
|
4
|
-
require 'shells/
|
5
|
-
require 'shells/
|
6
|
-
require 'shells/
|
7
|
-
require 'shells/
|
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
|
##
|
data/lib/shells/bash_common.rb
CHANGED
@@ -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
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
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}"
|
data/lib/shells/errors.rb
CHANGED
@@ -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
|
16
|
-
class
|
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 <
|
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 <
|
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
|
-
#
|
36
|
-
class
|
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
|
-
#
|
43
|
-
|
49
|
+
# Signals that we want to restart the device.
|
50
|
+
class RestartNow < Exception
|
51
|
+
|
52
|
+
end
|
53
|
+
|
44
54
|
|
45
55
|
##
|
46
|
-
# The
|
47
|
-
|
56
|
+
# The prompt text for the main menu.
|
57
|
+
MENU_PROMPT = 'Enter an option:'
|
48
58
|
|
49
59
|
##
|
50
|
-
# The
|
51
|
-
|
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
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
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
|
99
|
-
|
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
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
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
|
-
|
178
|
-
|
179
|
-
|
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
|
-
|
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
|
-
|
192
|
-
|
193
|
-
|
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
|
-
|
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
|
-
|
211
|
-
|
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
|
-
|
271
|
-
|
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
|
-
#
|
285
|
-
def
|
286
|
-
|
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
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
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
|
-
|
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
|