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