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