shells 0.1.23 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,23 @@
1
+ module Shells
2
+ class ShellBase
3
+ private
4
+
5
+ def regex_escape(text)
6
+ text
7
+ .gsub('\\', '\\\\')
8
+ .gsub('[', '\\[')
9
+ .gsub(']', '\\]')
10
+ .gsub('(', '\\(')
11
+ .gsub(')', '\\)')
12
+ .gsub('.', '\\.')
13
+ .gsub('*', '\\*')
14
+ .gsub('+', '\\+')
15
+ .gsub('?', '\\?')
16
+ .gsub('{', '\\{')
17
+ .gsub('}', '\\}')
18
+ .gsub('$', '\\$')
19
+ .gsub('^', '\\^')
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,188 @@
1
+ module Shells
2
+ class ShellBase
3
+
4
+ attr_accessor :run_flag
5
+ private :run_flag, :run_flag=
6
+
7
+ add_hook :on_init do |sh|
8
+ sh.instance_eval do
9
+ self.run_flag = false
10
+ end
11
+ end
12
+
13
+ ##
14
+ # Is the shell currently running?
15
+ def running?
16
+ run_flag
17
+ end
18
+
19
+ # the thread used to run the session.
20
+ attr_accessor :session_thread
21
+ private :session_thread, :session_thread=
22
+
23
+ # track exceptions raised during session execution.
24
+ attr_accessor :session_exception
25
+ private :session_exception, :session_exception=
26
+
27
+ ##
28
+ # Set to true to ignore IO errors.
29
+ attr_accessor :ignore_io_error
30
+ protected :ignore_io_error, :ignore_io_error=
31
+
32
+ add_hook :on_before_run do |sh|
33
+ sh.instance_eval do
34
+ self.session_exception = nil
35
+ self.ignore_io_error = false
36
+ end
37
+ end
38
+
39
+ add_hook :on_after_run do |sh|
40
+ sh.instance_eval do
41
+ if session_thread&.status
42
+ session_thread.exit
43
+ end
44
+ self.session_thread = nil
45
+ self.ignore_io_error = false
46
+ end
47
+ end
48
+
49
+ ##
50
+ # Runs a shell session.
51
+ #
52
+ # The block provided will be run asynchronously with the shell.
53
+ #
54
+ # Returns the shell instance.
55
+ def run(&block)
56
+ sync do
57
+ raise Shells::AlreadyRunning if running?
58
+ self.run_flag = true
59
+ end
60
+
61
+ begin
62
+ run_hook :on_before_run
63
+
64
+ debug 'Connecting...'
65
+ connect
66
+
67
+ debug 'Starting output buffering...'
68
+ buffer_output
69
+
70
+ debug 'Starting session thread...'
71
+ self.session_thread = Thread.start(self) do |sh|
72
+ begin
73
+ begin
74
+ debug 'Executing setup...'
75
+ sh.instance_eval { setup }
76
+ debug 'Executing block...'
77
+ block.call sh
78
+ ensure
79
+ debug 'Executing teardown...'
80
+ sh.instance_eval { teardown }
81
+ end
82
+ rescue Shells::QuitNow
83
+ # just exit the session.
84
+ rescue =>e
85
+ # if the exception is handled by the hook no further processing is required, otherwise we store the exception
86
+ # to propagate it in the main thread.
87
+ unless sh.run_hook(:on_exception, e) == :break
88
+ sh.sync { sh.instance_eval { self.session_exception = e } }
89
+ end
90
+ end
91
+ end
92
+
93
+ # process the input buffer while the thread is alive and the shell is active.
94
+ debug 'Entering IO loop...'
95
+ io_loop do
96
+ if active?
97
+ begin
98
+ if session_thread.status # not dead
99
+ # process input from the session.
100
+ unless wait_for_output
101
+ inp = next_input
102
+ if inp
103
+ send_data inp
104
+ self.wait_for_output = (options[:unbuffered_input] == :echo)
105
+ end
106
+ end
107
+
108
+ # continue running the IO loop
109
+ true
110
+ elsif session_exception
111
+ # propagate the exception.
112
+ raise session_exception.class, session_exception.message, session_exception.backtrace
113
+ else
114
+ # the thread has exited, but no exception exists.
115
+ # regardless, the IO loop should now exit.
116
+ false
117
+ end
118
+ rescue IOError
119
+ if ignore_io_error
120
+ # we were (sort of) expecting the IO error, so just tell the IO loop to exit.
121
+ false
122
+ else
123
+ raise
124
+ end
125
+ end
126
+ else
127
+ # the shell session is no longer active, tell the IO loop to exit.
128
+ false
129
+ end
130
+ end
131
+ rescue
132
+ # when an error occurs, try to disconnect, but ignore any further errors.
133
+ begin
134
+ debug 'Disconnecting...'
135
+ disconnect
136
+ rescue
137
+ # ignore
138
+ end
139
+ raise
140
+ else
141
+ # when no error occurs, try to disconnect and propagate any errors (unless we are ignoring IO errors).
142
+ begin
143
+ debug 'Disconnecting...'
144
+ disconnect
145
+ rescue IOError
146
+ raise unless ignore_io_error
147
+ end
148
+ ensure
149
+ # cleanup
150
+ run_hook :on_after_run
151
+ self.run_flag = false
152
+ end
153
+
154
+ self
155
+ end
156
+
157
+ protected
158
+
159
+ ##
160
+ # Adds code to be run when an exception occurs.
161
+ #
162
+ # This code will receive the shell as the first argument and the exception as the second.
163
+ # If it handles the exception it should return :break.
164
+ #
165
+ # on_exception do |shell, ex|
166
+ # if ex.is_a?(MyExceptionType)
167
+ # ...
168
+ # :break
169
+ # else
170
+ # false
171
+ # end
172
+ # end
173
+ #
174
+ # You can also pass the name of a static method.
175
+ #
176
+ # def self.some_exception_handler(shell, ex)
177
+ # ...
178
+ # end
179
+ #
180
+ # on_exception :some_exception_handler
181
+ #
182
+ def self.on_exception(proc = nil, &block)
183
+ add_hook :on_exception, proc, &block
184
+ end
185
+
186
+
187
+ end
188
+ end
@@ -0,0 +1,24 @@
1
+ module Shells
2
+ class ShellBase
3
+
4
+ attr_accessor :thread_lock
5
+ private :thread_lock, :thread_lock=
6
+
7
+ add_hook :on_init do |sh|
8
+ puts 'Initializing...'
9
+ sh.instance_eval do
10
+ self.thread_lock = Mutex.new
11
+ end
12
+ end
13
+
14
+ protected
15
+
16
+ ##
17
+ # Synchronizes actions between shell threads.
18
+ def sync(&block)
19
+ thread_lock.synchronize &block
20
+ end
21
+
22
+
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ require 'shells/ssh_shell'
2
+ require 'shells/bash_common'
3
+
4
+ module Shells
5
+ ##
6
+ # Executes a Bash session with an SSH host.
7
+ #
8
+ # The default setup of this class should work well with any bash-like shell.
9
+ #
10
+ # Valid options:
11
+ # +host+::
12
+ # The name or IP address of the host to connect to. Defaults to 'localhost'.
13
+ # +port+::
14
+ # The port on the host to connect to. Defaults to 22.
15
+ # +user+::
16
+ # The user to login with. This option is required.
17
+ # +password+::
18
+ # The password to login with.
19
+ # If our public key is an authorized key on the host, the password is ignored for connection.
20
+ # The #sudo_exec method for bash-like shells will also use this password for elevation.
21
+ # +prompt+::
22
+ # The prompt used to determine when processes finish execution.
23
+ # Defaults to '~~#', but if that doesn't work for some reason because it is valid output from one or more
24
+ # commands, you can change it to something else. It must be unique and cannot contain certain characters.
25
+ # The characters you should avoid are !, $, \, /, ", and ' because no attempt is made to escape them and the
26
+ # resulting prompt can very easily become something else entirely. If they are provided, they will be
27
+ # replaced to protect the shell from getting stuck.
28
+ # +quit+::
29
+ # If set, this defines the command to execute when quitting the session.
30
+ # The default is "exit" which will probably work most of the time.
31
+ # +retrieve_exit_code+::
32
+ # If set to a non-false value, then the default behavior will be to retrieve the exit code from the shell after
33
+ # executing a command. If set to a false or nil value, the default behavior will be to ignore the exit code
34
+ # from the shell. When retrieved, the exit code is stored in the +last_exit_code+ property.
35
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
36
+ # +on_non_zero_exit_code+::
37
+ # If set to :ignore (the default) then non-zero exit codes will not cause errors. You will still be able to check
38
+ # the +last_exit_code+ property to determine if the command was successful.
39
+ # If set to :raise then non-zero exit codes will cause a Shells::NonZeroExitCode to be raised when a command exits
40
+ # with a non-zero return value.
41
+ # This option only comes into play when +retrieve_exit_code+ is set to a non-false value.
42
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
43
+ # +silence_timeout+::
44
+ # When a command is executing, this is the maximum amount of time to wait for any feedback from the shell.
45
+ # If set to 0 (or less) there is no timeout.
46
+ # Unlike +command_timeout+ this value resets every time we receive feedback.
47
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
48
+ # +command_timeout+::
49
+ # When a command is executing, this is the maximum amount of time to wait for the command to finish.
50
+ # If set to 0 (or less) there is no timeout.
51
+ # Unlike +silence_timeout+ this value does not reset when we receive feedback.
52
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
53
+ # +connect_timeout+::
54
+ # This is the maximum amount of time to wait for the initial connection to the SSH shell.
55
+ #
56
+ # Shells::SshBashShell.new(
57
+ # host: '10.10.10.10',
58
+ # user: 'somebody',
59
+ # password: 'super-secret'
60
+ # ) do |shell|
61
+ # shell.exec('cd /usr/local/bin')
62
+ # user_bin_files = shell.exec('ls -A1').split("\n")
63
+ # @app_is_installed = user_bin_files.include?('my_app')
64
+ # end
65
+ #
66
+ class SshBashShell < SshShell
67
+
68
+ include BashCommon
69
+
70
+ end
71
+ end
@@ -1,4 +1,4 @@
1
- require 'shells/ssh_session'
1
+ require 'shells/ssh_bash_shell'
2
2
  require 'shells/pf_sense_common'
3
3
 
4
4
  module Shells
@@ -29,25 +29,27 @@ module Shells
29
29
  # +connect_timeout+::
30
30
  # This is the maximum amount of time to wait for the initial connection to the SSH shell.
31
31
  #
32
- # Shells::PfSenseSshSession.new(
32
+ # Shells::SshPfSenseShell.new(
33
33
  # host: '10.10.10.10',
34
34
  # user: 'somebody',
35
35
  # password: 'super-secret'
36
36
  # ) do |shell|
37
- # cfg = shell.get_config_section("aliases")
38
- # cfg["alias"] ||= []
39
- # cfg["alias"] << {
40
- # :name => "MY_NETWORK",
41
- # :type => "network",
42
- # :address => "192.168.1.0/24",
43
- # :descr => "My home network",
44
- # :details => "Created #{Time.now.to_s}"
45
- # }
46
- # shell.set_config_section("aliases", cfg, "Add home network")
47
- # shell.apply_filter_config
37
+ # shell.pf_shell do |shell|
38
+ # cfg = shell.get_config_section("aliases")
39
+ # cfg["alias"] ||= []
40
+ # cfg["alias"] << {
41
+ # :name => "MY_NETWORK",
42
+ # :type => "network",
43
+ # :address => "192.168.1.0/24",
44
+ # :descr => "My home network",
45
+ # :details => "Created #{Time.now.to_s}"
46
+ # }
47
+ # shell.set_config_section("aliases", cfg, "Add home network")
48
+ # shell.apply_filter_config
49
+ # end
48
50
  # end
49
51
  #
50
- class PfSenseSshSession < SshSession
52
+ class SshPfSenseShell < SshBashShell
51
53
 
52
54
  include PfSenseCommon
53
55
 
@@ -0,0 +1,215 @@
1
+ require 'net/ssh'
2
+ require 'shells/shell_base'
3
+
4
+ module Shells
5
+ ##
6
+ # Executes an SSH session with a host.
7
+ #
8
+ # Valid options:
9
+ # +host+::
10
+ # The name or IP address of the host to connect to. Defaults to 'localhost'.
11
+ # +port+::
12
+ # The port on the host to connect to. Defaults to 22.
13
+ # +user+::
14
+ # The user to login with. This option is required.
15
+ # +password+::
16
+ # The password to login with.
17
+ # If our public key is an authorized key on the host, the password is ignored for connection.
18
+ # The #sudo_exec method for bash-like shells will also use this password for elevation.
19
+ # +prompt+::
20
+ # The prompt used to determine when processes finish execution.
21
+ # +shell+::
22
+ # If set to :shell, then the default shell is executed. This is the default value.
23
+ # If set to :none, then no shell is executed, but a PTY is still created.
24
+ # If set to :no_pty, then no shell is executed and no PTY is created.
25
+ # If set to anything else, it is assumed to be the executable path to the shell you want to run.
26
+ # +quit+::
27
+ # If set, this defines the command to execute when quitting the session.
28
+ # The default is "exit" which will probably work most of the time.
29
+ # +retrieve_exit_code+::
30
+ # If set to a non-false value, then the default behavior will be to retrieve the exit code from the shell after
31
+ # executing a command. If set to a false or nil value, the default behavior will be to ignore the exit code
32
+ # from the shell. When retrieved, the exit code is stored in the +last_exit_code+ property.
33
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
34
+ # +on_non_zero_exit_code+::
35
+ # If set to :ignore (the default) then non-zero exit codes will not cause errors. You will still be able to check
36
+ # the +last_exit_code+ property to determine if the command was successful.
37
+ # If set to :raise then non-zero exit codes will cause a Shells::NonZeroExitCode to be raised when a command exits
38
+ # with a non-zero return value.
39
+ # This option only comes into play when +retrieve_exit_code+ is set to a non-false value.
40
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
41
+ # +silence_timeout+::
42
+ # When a command is executing, this is the maximum amount of time to wait for any feedback from the shell.
43
+ # If set to 0 (or less) there is no timeout.
44
+ # Unlike +command_timeout+ this value resets every time we receive feedback.
45
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
46
+ # +command_timeout+::
47
+ # When a command is executing, this is the maximum amount of time to wait for the command to finish.
48
+ # If set to 0 (or less) there is no timeout.
49
+ # Unlike +silence_timeout+ this value does not reset when we receive feedback.
50
+ # This option can be overridden by providing an alternate value to the +exec+ method on a case-by-case basis.
51
+ # +connect_timeout+::
52
+ # This is the maximum amount of time to wait for the initial connection to the SSH shell.
53
+ #
54
+ # Shells::SshShell.new(
55
+ # host: '10.10.10.10',
56
+ # user: 'somebody',
57
+ # password: 'super-secret'
58
+ # ) do |shell|
59
+ # shell.exec('cd /usr/local/bin')
60
+ # user_bin_files = shell.exec('ls -A1').split("\n")
61
+ # @app_is_installed = user_bin_files.include?('my_app')
62
+ # end
63
+ #
64
+ class SshShell < Shells::ShellBase
65
+
66
+ ##
67
+ # The error raised when we failed to request a PTY.
68
+ class FailedToRequestPty < Shells::ShellError
69
+
70
+ end
71
+
72
+ ##
73
+ # The error raised when we fail to start the shell on the PTY.
74
+ class FailedToStartShell < Shells::ShellError
75
+
76
+ end
77
+
78
+ attr_accessor :ssh, :channel
79
+ private :ssh, :ssh=, :channel, :channel=
80
+
81
+ add_hook :on_before_run do |sh|
82
+ sh.instance_eval do
83
+ self.ssh = nil
84
+ self.channel = nil
85
+ end
86
+ end
87
+
88
+ add_hook :on_after_run do |sh|
89
+ sh.instance_eval do
90
+ self.ssh = nil
91
+ self.channel = nil
92
+ end
93
+ end
94
+
95
+ protected
96
+
97
+ def validate_options #:nodoc:
98
+ options[:host] ||= 'localhost'
99
+ options[:port] ||= 22
100
+ options[:shell] ||= :shell
101
+ options[:quit] ||= 'exit'
102
+ options[:connect_timeout] ||= 5
103
+
104
+ raise InvalidOption, 'Missing host.' if options[:host].to_s.strip == ''
105
+ raise InvalidOption, 'Missing user.' if options[:user].to_s.strip == ''
106
+ end
107
+
108
+
109
+ def connect #:nodoc:
110
+
111
+ debug 'Connecting to SSH host...'
112
+ self.ssh = Net::SSH.start(
113
+ options[:host],
114
+ options[:user],
115
+ password: options[:password],
116
+ port: options[:port],
117
+ non_interactive: true,
118
+ timeout: options[:connect_timeout]
119
+ )
120
+ debug ' > connected'
121
+
122
+ opened = false
123
+
124
+ debug 'Opening channel...'
125
+ self.channel = ssh.open_channel do |ch|
126
+ opened = true
127
+ end
128
+
129
+ io_loop { !opened }
130
+ debug ' > opened'
131
+
132
+ end
133
+
134
+ def setup #:nodoc:
135
+ done = false
136
+ unless options[:shell] == :no_pty
137
+ debug 'Acquiring PTY...'
138
+ channel.request_pty do |_, success|
139
+ raise FailedToRequestPty unless success
140
+ debug ' > acquired'
141
+ done = true
142
+ end
143
+ end
144
+
145
+ until done
146
+ sleep 0.0001
147
+ end
148
+
149
+ done = false
150
+ unless [:no_pty,:none].include?(options[:shell])
151
+ debug 'Starting shell...'
152
+ # pick a method to start the shell with.
153
+ meth = (options[:shell] == :shell) ? :send_channel_request : :exec
154
+ channel.send(meth, options[:shell].to_s) do |_, success|
155
+ raise FailedToStartShell unless success
156
+ debug ' > started'
157
+ done = true
158
+ end
159
+ end
160
+
161
+ until done
162
+ sleep 0.0001
163
+ end
164
+
165
+ debug 'Calling setup_prompt...'
166
+ setup_prompt
167
+ debug ' > prompt setup'
168
+ end
169
+
170
+ def disconnect #:nodoc:
171
+ debug 'Marking channel for closure...'
172
+ channel.close
173
+ debug ' > marked'
174
+ debug 'Closing SSH connection...'
175
+ ssh.close
176
+ debug ' > closed'
177
+ end
178
+
179
+ def send_data(data) #:nodoc:
180
+ channel.send_data data
181
+ debug "Sent: (#{data.size} bytes) #{(data.size > 32 ? (data[0..30] + '...') : data).inspect}"
182
+ end
183
+
184
+ def stdout_received(&block) #:nodoc:
185
+ channel.on_data do |_,data|
186
+ debug "Received: (#{data.size} bytes) #{(data.size > 32 ? (data[0..30] + '...') : data).inspect}"
187
+ block.call data
188
+ end
189
+ end
190
+
191
+ def stderr_received(&block) #:nodoc:
192
+ channel.on_extended_data do |_, type, data|
193
+ if type == 1
194
+ debug "Received: (#{data.size} bytes) [E] #{(data.size > 32 ? (data[0..30] + '...') : data).inspect}"
195
+ block.call data
196
+ else
197
+ debug "Received: (#{data.size} bytes) [#{type}] #{(data.size > 32 ? (data[0..30] + '...') : data).inspect}"
198
+ end
199
+ end
200
+ end
201
+
202
+ def active?
203
+ channel&.active?
204
+ end
205
+
206
+ def io_loop(&block)
207
+ shell = self
208
+ ssh&.loop(0.000001) do |_|
209
+ shell.instance_eval &block
210
+ end
211
+ end
212
+
213
+
214
+ end
215
+ end