barkest_ssh 1.1.13
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 +7 -0
- data/.gitignore +13 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +130 -0
- data/Rakefile +2 -0
- data/barkest_ssh.gemspec +26 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/barkest_ssh.rb +7 -0
- data/lib/barkest_ssh/secure_shell.rb +592 -0
- data/lib/barkest_ssh/version.rb +3 -0
- metadata +111 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 11900aa2618a5ff0ca84feb5c426e729d9b802f5
|
4
|
+
data.tar.gz: 4bc6cf36fde39a99e637bfc6a1089035c9836e4c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fb5b9bede7905f66967d275e1bb9feb8aaceaefd48759a8c2f32ffab550956dc54357990cb283e1eb42dd617aa0c7d1f0246f5cb600d68171643c9d1014f55a2
|
7
|
+
data.tar.gz: 21daaa9703b71cbad298aeadb38d61903014daa75a2a4e9b375f1f1e6c0f2cf3d14922e351766ebe27e20970b02d28ac93f1c61f10fa179e72a3a5bbd6714e1e
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Beau Barker
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
# BarkestSsh
|
2
|
+
|
3
|
+
The BarkestSsh gem is a very simple wrapper around Net::SSH. Using the BarkestSsh::SecureShell class you can execute a
|
4
|
+
shell session on a local or remote host. Primarily targeted at `bash` shells, this gem may have trouble interacting
|
5
|
+
with some hardware due to some assumptions it makes.
|
6
|
+
|
7
|
+
For instance, it expects to be able to set the `PS1` variable to use a custom prompt so it knows when command execution
|
8
|
+
has officially completed. A possible workaround if the device has a static shell is to set the prompt option to match
|
9
|
+
the static shell. If the device has a dynamic shell, but a static final sequence (ie - '>', '$', or '#') then setting
|
10
|
+
the prompt option to that value may also work. However, there may be an issue with false positives in this situation.
|
11
|
+
|
12
|
+
This gem was primarily developed to be added into [Barker EST](http://www.barkerest.com/) web applications (hence the name).
|
13
|
+
|
14
|
+
|
15
|
+
## Installation
|
16
|
+
|
17
|
+
Add this line to your application's Gemfile:
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
gem 'barkest_ssh'
|
21
|
+
```
|
22
|
+
|
23
|
+
And then execute:
|
24
|
+
|
25
|
+
$ bundle
|
26
|
+
|
27
|
+
Or install it yourself as:
|
28
|
+
|
29
|
+
$ gem install barkest_ssh
|
30
|
+
|
31
|
+
## Usage
|
32
|
+
|
33
|
+
All of the work is done in the BarkestSsh::SecureShell constructor. Provide a code block to BarkestSsh::SecureShell.new
|
34
|
+
with the actions you want to execute remotely.
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
BarkestSsh::SecureShell.new(
|
38
|
+
host: '10.10.10.10',
|
39
|
+
user: 'somebody',
|
40
|
+
password: 'super-secret'
|
41
|
+
) do |shell|
|
42
|
+
shell.exec('cd /usr/local/bin')
|
43
|
+
user_bin_files = shell.exec('ls -A1').split('\n')
|
44
|
+
@app_is_installed = user_bin_files.include?('my_app')
|
45
|
+
end
|
46
|
+
```
|
47
|
+
|
48
|
+
|
49
|
+
The following methods are available within the code block:
|
50
|
+
|
51
|
+
* __`exec(command, options = {}, &block)`__
|
52
|
+
|
53
|
+
This is the core method that will probably be used most often. The command is executed and the text sent
|
54
|
+
to STDOUT and STDERR is returned as a single string for you to process. The options parameter can set the
|
55
|
+
`:on_non_zero_exit_code` option to :default, :ignore, or :raise_error. If a block is provided, the block
|
56
|
+
will be called anytime data is received. The block will receive data and type parameters. Type will indicate
|
57
|
+
if the data was sent to STDOUT or STDERR. If the block returns a value, that value will be sent to the shell.
|
58
|
+
This can be used to interact with the shell.
|
59
|
+
|
60
|
+
* __`exec_ignore(command, &block)`__
|
61
|
+
|
62
|
+
Wrapper for `exec` that sets :on_non_zero_exit_code to :ignore.
|
63
|
+
|
64
|
+
* __`exec_raise(command, &block)`__
|
65
|
+
|
66
|
+
Wrapper for `exec` that sets :on_non_zero_exit_code to :raise_error.
|
67
|
+
|
68
|
+
* __`sudo_exec(command, options = {}, &block)`__
|
69
|
+
|
70
|
+
Wrapper for `exec` that attempts to elevate the command to run as root. This will only work if the user
|
71
|
+
that the shell is connected with is a sudoer on the target host. Also, it requires that the target host
|
72
|
+
uses the `sudo` command and `bash` shell.
|
73
|
+
|
74
|
+
* __`sudo_exec_ignore(command, &block)`__
|
75
|
+
|
76
|
+
Wrapper for `sudo_exec` that sets :on_non_zero_exit_code to :ignore.
|
77
|
+
|
78
|
+
* __`sudo_exec_raise(command, &block)`__
|
79
|
+
|
80
|
+
Wrapper for `sudo_exec` that sets :on_non_zero_exit_code to :raise_error.
|
81
|
+
|
82
|
+
* __`last_exit_code`__
|
83
|
+
|
84
|
+
Gets the exit code from the last command, if the shell is configured to retrieve the exit codes.
|
85
|
+
|
86
|
+
* __`stdout`__
|
87
|
+
|
88
|
+
Gets the output to STDOUT for the shell session.
|
89
|
+
|
90
|
+
* __`stderr`__
|
91
|
+
|
92
|
+
Gets the output to STDERR for the shell session.
|
93
|
+
|
94
|
+
* __`combined_output`__
|
95
|
+
|
96
|
+
Gets the output to both STDOUT and STDERR combined together for the shell session.
|
97
|
+
|
98
|
+
* __`upload(local_file, remote_file)`__
|
99
|
+
|
100
|
+
Uses a SFTP channel to upload a file to the host. The first time a SFTP method is used a second
|
101
|
+
SSH connection is made to the host and a SFTP channel is created.
|
102
|
+
|
103
|
+
* __`download(remote_file, local_file)`__
|
104
|
+
|
105
|
+
Uses a SFTP channel to download a file from the host. The first time a SFTP method is used a second
|
106
|
+
SSH connection is made to the host and a SFTP channel is created.
|
107
|
+
|
108
|
+
* __`read_file(remote_file)`__
|
109
|
+
|
110
|
+
Uses a SFTP channel to download a file from the host and returns the contents as a string.
|
111
|
+
The first time a SFTP method is used a second SSH connection is made to the host and a SFTP channel
|
112
|
+
is created.
|
113
|
+
|
114
|
+
* __`write_file(remote_file, data)`__
|
115
|
+
|
116
|
+
Uses a SFTP channel to upload a file to the host from an in-memory string.
|
117
|
+
The first time a SFTP method is used a second SSH connection is made to the host and a SFTP channel
|
118
|
+
is created.
|
119
|
+
|
120
|
+
|
121
|
+
## Contributing
|
122
|
+
|
123
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/barkerest/barkest_ssh.
|
124
|
+
|
125
|
+
|
126
|
+
## License
|
127
|
+
|
128
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
129
|
+
|
130
|
+
Copyright (c) 2016 [Beau Barker](mailto:beau@barkerest.com)
|
data/Rakefile
ADDED
data/barkest_ssh.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'barkest_ssh/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "barkest_ssh"
|
8
|
+
spec.version = BarkestSsh::VERSION
|
9
|
+
spec.authors = ["Beau Barker"]
|
10
|
+
spec.email = ["beau@barkerest.com"]
|
11
|
+
|
12
|
+
spec.summary = "Provides a very simple interface to Net::SSH."
|
13
|
+
spec.homepage = "http://www.barkerest.com/"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
17
|
+
spec.bindir = "exe"
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency 'net-ssh', '~> 3.0.2'
|
22
|
+
spec.add_dependency 'net-sftp', '~> 2.1.2'
|
23
|
+
|
24
|
+
spec.add_development_dependency 'bundler', '~> 1.12'
|
25
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
26
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "barkest_ssh"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/lib/barkest_ssh.rb
ADDED
@@ -0,0 +1,592 @@
|
|
1
|
+
|
2
|
+
require 'net/ssh'
|
3
|
+
require 'net/sftp'
|
4
|
+
|
5
|
+
module BarkestSsh
|
6
|
+
|
7
|
+
##
|
8
|
+
# The SecureShell class is used to run an SSH session with a local or remote host.
|
9
|
+
#
|
10
|
+
# This is a wrapper for Net::SSH that starts a shell session and executes a block.
|
11
|
+
# All of the output from the session is cached for later use as needed.
|
12
|
+
#
|
13
|
+
class SecureShell
|
14
|
+
|
15
|
+
##
|
16
|
+
# An error occurring within the SecureShell class aside from argument errors.
|
17
|
+
ShellError = Class.new(StandardError)
|
18
|
+
|
19
|
+
##
|
20
|
+
# An exception raised when a command requiring a connection is attempted after the connection has been closed.
|
21
|
+
ConnectionClosed = Class.new(ShellError)
|
22
|
+
|
23
|
+
##
|
24
|
+
# An exception raised when the SSH session fails to request a PTY.
|
25
|
+
FailedToRequestPTY = Class.new(ShellError)
|
26
|
+
|
27
|
+
##
|
28
|
+
# An exception raised when the SSH session fails to start a shell.
|
29
|
+
FailedToStartShell = Class.new(ShellError)
|
30
|
+
|
31
|
+
##
|
32
|
+
# An exception raised when the SSH shell session fails to execute.
|
33
|
+
#
|
34
|
+
FailedToExecute = Class.new(ShellError)
|
35
|
+
|
36
|
+
##
|
37
|
+
# An exception raised when the shell session is silent too long.
|
38
|
+
LongSilence = Class.new(ShellError)
|
39
|
+
|
40
|
+
##
|
41
|
+
# A command exited with a non-zero status.
|
42
|
+
NonZeroExitCode = Class.new(ShellError)
|
43
|
+
|
44
|
+
##
|
45
|
+
# Creates a SecureShell session and executes the provided block.
|
46
|
+
#
|
47
|
+
# You must provide a code block to run within the shell session, the session is closed before this returns.
|
48
|
+
#
|
49
|
+
# Valid options:
|
50
|
+
# * +host+
|
51
|
+
# The name or IP address of the host to connect to. Defaults to 'localhost'.
|
52
|
+
# * +port+
|
53
|
+
# The port on the host to connect to. Defaults to 22.
|
54
|
+
# * +user+
|
55
|
+
# The user to login with.
|
56
|
+
# * +password+
|
57
|
+
# The password to login with.
|
58
|
+
# * +prompt+
|
59
|
+
# The prompt used to determine when processes finish execution.
|
60
|
+
# Defaults to '~~#', but if that doesn't work for some reason because it is valid output from one or more
|
61
|
+
# commands, you can change it to something else. It must be unique and cannot contain certain characters.
|
62
|
+
# The characters you should avoid are !, $, \, /, ", and ' because no attempt is made to escape them and the
|
63
|
+
# resulting prompt can very easily become something else entirely. If they are provided, they will be
|
64
|
+
# replaced to protect the shell from getting stuck.
|
65
|
+
# * +silence_wait+
|
66
|
+
# The number of seconds to wait when the shell is not sending back data to send a newline. This can help
|
67
|
+
# battle background tasks burying the prompt, but it might not play nice with long-running foreground tasks.
|
68
|
+
# The default is 5 seconds, if you notice problems, set this to a higher value, or 0 to disable.
|
69
|
+
# During extended silence, the first time this value elapses, the shell will send the newline, the second time
|
70
|
+
# the shell will error out.
|
71
|
+
# * +replace_cr+
|
72
|
+
# The string to replace stand-alone CR characters with. The default is an empty string (ie - remove them).
|
73
|
+
# You may also want to replace with a LF character instead, which is the behavior taken when a CR+ LF sequence
|
74
|
+
# is encountered. A space followed by a standalone CR is treated differently since these seem to occur when
|
75
|
+
# the terminal ouput wraps. In these cases, the SPACE + CR sequence is simply removed.
|
76
|
+
# * +retrieve_exit_code+
|
77
|
+
# Version 1.1.10 introduces support for grabbing the exit code from the last command and then performing an
|
78
|
+
# action. The default value is true, but if you set this to false then the shell will not retrieve the exit
|
79
|
+
# codes automatically.
|
80
|
+
# * +on_non_zero_exit_code+
|
81
|
+
# If the exit code is non-zero, the default behavior (to remain compatible with prior versions) is to
|
82
|
+
# ignore the exit code. You can also set this to :raise_error to raise the NonZeroExitCode error.
|
83
|
+
# * +filter_password+
|
84
|
+
# As a convenience, if this is set to true (the default), then any text matching the configured password
|
85
|
+
# will be replaced with a series of asterisks in the output.
|
86
|
+
#
|
87
|
+
# SecureShell.new(
|
88
|
+
# host: '10.10.10.10',
|
89
|
+
# user: 'somebody',
|
90
|
+
# password: 'super-secret'
|
91
|
+
# ) do |shell|
|
92
|
+
# shell.exec('cd /usr/local/bin')
|
93
|
+
# user_bin_files = shell.exec('ls -A1').split('\n')
|
94
|
+
# @app_is_installed = user_bin_files.include?('my_app')
|
95
|
+
# end
|
96
|
+
#
|
97
|
+
def initialize(options = {}, &block)
|
98
|
+
options ||= {}
|
99
|
+
@options = {
|
100
|
+
host: options[:host] || 'localhost',
|
101
|
+
port: options[:port] || 22,
|
102
|
+
user: options[:user],
|
103
|
+
password: options[:password],
|
104
|
+
prompt: (options[:prompt].to_s.strip == '') ? '~~#' : options[:prompt],
|
105
|
+
silence_wait: (options[:silence_wait] || 5),
|
106
|
+
replace_cr: options[:replace_cr].to_s,
|
107
|
+
retrieve_exit_code: options[:retrieve_exit_code].nil? ? true : options[:retrieve_exit_code],
|
108
|
+
on_non_zero_exit_code: options[:on_non_zero_exit_code] ? options[:on_non_zero_exit_code].to_s.to_sym : :ignore,
|
109
|
+
filter_password: options[:filter_password].nil? ? true : options[:filter_password],
|
110
|
+
}
|
111
|
+
|
112
|
+
raise ArgumentError.new('Missing block.') unless block_given?
|
113
|
+
raise ArgumentError.new('Missing host.') if @options[:host].to_s.strip == ''
|
114
|
+
raise ArgumentError.new('Missing user.') if @options[:user].to_s.strip == ''
|
115
|
+
raise ArgumentError.new('Missing password.') if @options[:password].to_s.strip == ''
|
116
|
+
raise ArgumentError.new('Missing prompt.') if @options[:prompt].to_s.strip == ''
|
117
|
+
raise ArgumentError.new('Invalid option for on_non_zero_exit_code.') unless [:ignore, :raise_error].include?(@options[:on_non_zero_exit_code])
|
118
|
+
|
119
|
+
@options[:prompt] = @options[:prompt]
|
120
|
+
.gsub('!', '#')
|
121
|
+
.gsub('$', '#')
|
122
|
+
.gsub('\\', '.')
|
123
|
+
.gsub('/', '.')
|
124
|
+
.gsub('"', '-')
|
125
|
+
.gsub('\'', '-')
|
126
|
+
|
127
|
+
executed = false
|
128
|
+
|
129
|
+
@last_exit_code = 0
|
130
|
+
@sftp = nil
|
131
|
+
Net::SSH.start(
|
132
|
+
@options[:host],
|
133
|
+
@options[:user],
|
134
|
+
password: @options[:password],
|
135
|
+
port: @options[:port],
|
136
|
+
non_interactive: true,
|
137
|
+
) do |ssh|
|
138
|
+
@ssh = ssh
|
139
|
+
ssh.open_channel do |ssh_channel|
|
140
|
+
ssh_channel.request_pty do |pty_channel, pty_success|
|
141
|
+
raise FailedToRequestPTY.new('Failed to request PTY.') unless pty_success
|
142
|
+
|
143
|
+
pty_channel.send_channel_request('shell') do |_, shell_success|
|
144
|
+
raise FailedToStartShell.new('Failed to start shell.') unless shell_success
|
145
|
+
|
146
|
+
# cache the channel pointer and start buffering the input.
|
147
|
+
@channel = pty_channel
|
148
|
+
buffer_input
|
149
|
+
|
150
|
+
# give the shell a chance to catch up and initialize fully.
|
151
|
+
sleep 0.25
|
152
|
+
|
153
|
+
# set the shell prompt so that we can determine when processes end.
|
154
|
+
# does not work with background processes since we are looking for
|
155
|
+
# the shell to send us this when it is ready for more input.
|
156
|
+
# a background process can easily bury the prompt and then we are stuck in a loop.
|
157
|
+
exec "PS1=\"#{@options[:prompt]}\""
|
158
|
+
|
159
|
+
block.call(self)
|
160
|
+
|
161
|
+
executed = true
|
162
|
+
|
163
|
+
# send the exit command and remove the channel pointer.
|
164
|
+
quit
|
165
|
+
@channel = nil
|
166
|
+
end
|
167
|
+
end
|
168
|
+
ssh_channel.wait
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
@ssh = nil
|
173
|
+
|
174
|
+
if @sftp
|
175
|
+
@sftp.session.close
|
176
|
+
@sftp = nil
|
177
|
+
end
|
178
|
+
|
179
|
+
# remove the cached user and password.
|
180
|
+
options.delete(:user)
|
181
|
+
options.delete(:password)
|
182
|
+
|
183
|
+
raise FailedToExecute.new('Failed to execute shell.') unless executed
|
184
|
+
end
|
185
|
+
|
186
|
+
##
|
187
|
+
# Gets the last exit code.
|
188
|
+
def last_exit_code
|
189
|
+
@last_exit_code || 0
|
190
|
+
end
|
191
|
+
|
192
|
+
##
|
193
|
+
# Wrapper for +exec+ that will ignore non-zero exit codes.
|
194
|
+
def exec_ignore(command, &block)
|
195
|
+
exec command, on_non_zero_exit_code: :ignore, &block
|
196
|
+
end
|
197
|
+
|
198
|
+
##
|
199
|
+
# Wrapper for +exec+ that will raise an error on non-zero exit codes.
|
200
|
+
def exec_raise(command, &block)
|
201
|
+
exec command, on_non_zero_exit_code: :raise_error, &block
|
202
|
+
end
|
203
|
+
|
204
|
+
##
|
205
|
+
# Executes a command during the shell session.
|
206
|
+
#
|
207
|
+
# If called outside of the +new+ block, this will raise an error.
|
208
|
+
#
|
209
|
+
# The +command+ is the command to execute in the shell.
|
210
|
+
#
|
211
|
+
# The +options+ parameter can include the following keys.
|
212
|
+
# * The :on_non_zero_exit_code option can be :default, :ignore, or :raise_error.
|
213
|
+
#
|
214
|
+
# If provided, the +block+ is a chunk of code that will be processed every time the
|
215
|
+
# shell receives output from the program. If the block returns a string, the string
|
216
|
+
# will be sent to the shell. This can be used to monitor processes or monitor and
|
217
|
+
# interact with processes. The +block+ is optional.
|
218
|
+
#
|
219
|
+
# shell.exec('sudo -p "password:" nginx restart') do |data,type|
|
220
|
+
# return 'super-secret' if /password:$/.match(data)
|
221
|
+
# nil
|
222
|
+
# end
|
223
|
+
#
|
224
|
+
def exec(command, options={}, &block)
|
225
|
+
raise ConnectionClosed.new('Connection is closed.') unless @channel
|
226
|
+
|
227
|
+
options = {
|
228
|
+
on_non_zero_exit_code: :default
|
229
|
+
}.merge(options || {})
|
230
|
+
|
231
|
+
options[:on_non_zero_exit_code] = @options[:on_non_zero_exit_code] if options[:on_non_zero_exit_code] == :default
|
232
|
+
|
233
|
+
push_buffer # store the current buffer and start a fresh buffer
|
234
|
+
|
235
|
+
# buffer while also passing data to the supplied block.
|
236
|
+
if block_given?
|
237
|
+
buffer_input( &block )
|
238
|
+
end
|
239
|
+
|
240
|
+
# send the command and wait for the prompt to return.
|
241
|
+
@channel.send_data command + "\n"
|
242
|
+
wait_for_prompt
|
243
|
+
|
244
|
+
# return buffering to normal.
|
245
|
+
if block_given?
|
246
|
+
buffer_input
|
247
|
+
end
|
248
|
+
|
249
|
+
# get the output from the command, minus the trailing prompt.
|
250
|
+
ret = command_output(command)
|
251
|
+
|
252
|
+
# restore the original buffer and merge the output from the command.
|
253
|
+
pop_merge_buffer
|
254
|
+
|
255
|
+
if @options[:retrieve_exit_code]
|
256
|
+
# get the exit code for the command.
|
257
|
+
push_buffer
|
258
|
+
retrieve_command = 'echo $?'
|
259
|
+
@channel.send_data retrieve_command + "\n"
|
260
|
+
wait_for_prompt
|
261
|
+
@last_exit_code = command_output(retrieve_command).strip.to_i
|
262
|
+
# restore the original buffer and discard the output from this command.
|
263
|
+
pop_discard_buffer
|
264
|
+
|
265
|
+
# if we are expected to raise an error, do so.
|
266
|
+
if options[:on_non_zero_exit_code] == :raise_error
|
267
|
+
raise NonZeroExitCode.new("Exit code was #{@last_exit_code}.") unless @last_exit_code == 0
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
ret
|
272
|
+
end
|
273
|
+
|
274
|
+
##
|
275
|
+
# Wrapper for +sudo_exec+ that will ignore non-zero exit codes.
|
276
|
+
def sudo_exec_ignore(command, &block)
|
277
|
+
sudo_exec command, on_non_zero_exit_code: :ignore, &block
|
278
|
+
end
|
279
|
+
|
280
|
+
##
|
281
|
+
# Wrapper for +sudo_exec+ that will raise an error on non-zero exit codes.
|
282
|
+
def sudo_exec_raise(command, &block)
|
283
|
+
sudo_exec command, on_non_zero_exit_code: :raise_error, &block
|
284
|
+
end
|
285
|
+
|
286
|
+
##
|
287
|
+
# Executes a command using +sudo+ during the shell session.
|
288
|
+
#
|
289
|
+
# This is a wrapper around +exec+ that attempts to run the command as root.
|
290
|
+
# It provides the configured user's password if/when prompted.
|
291
|
+
#
|
292
|
+
# See +exec+ for more information.
|
293
|
+
def sudo_exec(command, options = {}, &block)
|
294
|
+
sudo_prompt = '[sp:'
|
295
|
+
sudo_match = /(\r|\n)\[sp\:$/
|
296
|
+
sudo_strip = /\[sp\:\n/
|
297
|
+
ret = exec("sudo -p \"#{sudo_prompt}\" bash -c \"#{command.gsub('"', '\\"')}\"", options) do |data,type|
|
298
|
+
test_data = data.to_s
|
299
|
+
desired_length = sudo_prompt.length + 1 # prefix a NL before the prompt.
|
300
|
+
|
301
|
+
# pull from the current stdout to get the full test data, but only if we received some new data.
|
302
|
+
if test_data.length > 0 && test_data.length < desired_length
|
303
|
+
test_data = stdout[-desired_length..-1].to_s
|
304
|
+
end
|
305
|
+
|
306
|
+
if sudo_match.match(test_data)
|
307
|
+
@options[:password]
|
308
|
+
else
|
309
|
+
if block
|
310
|
+
block.call(data, type)
|
311
|
+
else
|
312
|
+
nil
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
# remove the sudo prompts.
|
317
|
+
ret.gsub(sudo_strip, '')
|
318
|
+
end
|
319
|
+
|
320
|
+
##
|
321
|
+
# Uses SFTP to upload a single file to the host.
|
322
|
+
def upload(local_file, remote_file)
|
323
|
+
raise ConnectionClosed.new('Connection is closed.') unless @ssh
|
324
|
+
sftp.upload!(local_file, remote_file)
|
325
|
+
end
|
326
|
+
|
327
|
+
##
|
328
|
+
# Uses SFTP to download a single file from the host.
|
329
|
+
def download(remote_file, local_file)
|
330
|
+
raise ConnectionClosed.new('Connection is closed.') unless @ssh
|
331
|
+
sftp.download!(remote_file, local_file)
|
332
|
+
end
|
333
|
+
|
334
|
+
##
|
335
|
+
# Uses SFTP to read the contents of a single file.
|
336
|
+
#
|
337
|
+
# Returns the contents of the file.
|
338
|
+
def read_file(remote_file)
|
339
|
+
raise ConnectionClosed.new('Connection is closed.') unless @ssh
|
340
|
+
sftp.download!(remote_file)
|
341
|
+
end
|
342
|
+
|
343
|
+
##
|
344
|
+
# Uses SFTP to write data to a single file.
|
345
|
+
def write_file(remote_file, data)
|
346
|
+
raise ConnectionClosed.new('Connection is closed.') unless @ssh
|
347
|
+
sftp.file.open(remote_file, 'w') do |f|
|
348
|
+
f.write data
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
##
|
353
|
+
# Gets the standard output from the session.
|
354
|
+
#
|
355
|
+
# The prompts are stripped from the standard ouput as they are encountered.
|
356
|
+
# So this will be a list of commands with their output.
|
357
|
+
#
|
358
|
+
# All line endings are converted to LF characters, so you will not
|
359
|
+
# encounter or need to search for CRLF or CR sequences.
|
360
|
+
#
|
361
|
+
def stdout
|
362
|
+
@stdout || ''
|
363
|
+
end
|
364
|
+
|
365
|
+
##
|
366
|
+
# Gets the error output from the session.
|
367
|
+
#
|
368
|
+
# All line endings are converted to LF characters, so you will not
|
369
|
+
# encounter or need to search for CRLF or CR sequences.
|
370
|
+
#
|
371
|
+
def stderr
|
372
|
+
@stderr || ''
|
373
|
+
end
|
374
|
+
|
375
|
+
##
|
376
|
+
# Gets both the standard output and error output from the session.
|
377
|
+
#
|
378
|
+
# The prompts will be included in the combined output.
|
379
|
+
# There is no attempt to differentiate error output from standard output.
|
380
|
+
#
|
381
|
+
# This is essentially the definitive log for the session.
|
382
|
+
#
|
383
|
+
# All line endings are converted to LF characters, so you will not
|
384
|
+
# encounter or need to search for CRLF or CR sequences.
|
385
|
+
#
|
386
|
+
def combined_output
|
387
|
+
@stdcomb || ''
|
388
|
+
end
|
389
|
+
|
390
|
+
private
|
391
|
+
|
392
|
+
def quit
|
393
|
+
raise ConnectionClosed.new('Connection is closed.') unless @channel
|
394
|
+
@channel.send_data("exit\n")
|
395
|
+
@channel.wait
|
396
|
+
end
|
397
|
+
|
398
|
+
def command_output(command)
|
399
|
+
# get everyting except for the ending prompt.
|
400
|
+
ret = combined_output[0...-(@options[:prompt].length)]
|
401
|
+
# return the output from the command starting with the second line.
|
402
|
+
# the first line is the command sent to the shell.
|
403
|
+
# We also check for those rare times when a prompt manages to sneak in, trimming them off the front as well.
|
404
|
+
result_cmd,_,result_data = ret.partition("\n")
|
405
|
+
cmd_with_prompt = @options[:prompt] + command
|
406
|
+
until result_cmd == command || result_cmd == cmd_with_prompt || result_data.to_s.strip == ''
|
407
|
+
result_cmd,_,result_data = result_data.partition("\n")
|
408
|
+
end
|
409
|
+
result_data
|
410
|
+
end
|
411
|
+
|
412
|
+
def stdout_hist
|
413
|
+
@stdout_hist ||= []
|
414
|
+
end
|
415
|
+
|
416
|
+
def stderr_hist
|
417
|
+
@stderr_hist ||= []
|
418
|
+
end
|
419
|
+
|
420
|
+
def stdcomb_hist
|
421
|
+
@stdcom_hist ||= []
|
422
|
+
end
|
423
|
+
|
424
|
+
def prompted?
|
425
|
+
@prompted ||= false
|
426
|
+
end
|
427
|
+
|
428
|
+
def reset_prompted
|
429
|
+
@prompted = false
|
430
|
+
end
|
431
|
+
|
432
|
+
def set_prompted
|
433
|
+
@prompted = true
|
434
|
+
end
|
435
|
+
|
436
|
+
def push_buffer
|
437
|
+
# push the buffer so we can get the output of a command.
|
438
|
+
stdout_hist.push stdout
|
439
|
+
stderr_hist.push stderr
|
440
|
+
stdcomb_hist.push combined_output
|
441
|
+
@stdout = ''
|
442
|
+
@stderr = ''
|
443
|
+
@stdcomb = ''
|
444
|
+
end
|
445
|
+
|
446
|
+
def pop_merge_buffer
|
447
|
+
# almost a standard pop, however we want to merge history with current.
|
448
|
+
if (hist = stdout_hist.pop)
|
449
|
+
@stdout = hist + stdout
|
450
|
+
end
|
451
|
+
if (hist = stderr_hist.pop)
|
452
|
+
@stderr = hist + stderr
|
453
|
+
end
|
454
|
+
if (hist = stdcomb_hist.pop)
|
455
|
+
@stdcomb = hist + combined_output
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
def pop_discard_buffer
|
460
|
+
# a standard pop discarding current data and retrieving the history.
|
461
|
+
if (hist = stdout_hist.pop)
|
462
|
+
@stdout = hist
|
463
|
+
end
|
464
|
+
if (hist = stderr_hist.pop)
|
465
|
+
@stderr = hist
|
466
|
+
end
|
467
|
+
if (hist = stdcomb_hist.pop)
|
468
|
+
@stdcomb = hist
|
469
|
+
end
|
470
|
+
end
|
471
|
+
|
472
|
+
def append_stdout(data, &block)
|
473
|
+
# Combined output gets the prompts,
|
474
|
+
# but stdout will be without prompts.
|
475
|
+
# CRLF are converted to LF and CR are removed.
|
476
|
+
# The " \r" sequence appears to be a line continuation sequence for the shell, so it get's removed.
|
477
|
+
# All remaining CR are replaced with LF.
|
478
|
+
data = data.gsub("\r\n", "\n").gsub(" \r", '').gsub("\r", @options[:replace_cr])
|
479
|
+
|
480
|
+
for_stdout = if data[-(@options[:prompt].length)..-1] == @options[:prompt]
|
481
|
+
set_prompted
|
482
|
+
data[0...-(@options[:prompt].length)]
|
483
|
+
else
|
484
|
+
data
|
485
|
+
end
|
486
|
+
|
487
|
+
@stdout = @stdout.to_s + for_stdout
|
488
|
+
@stdcomb = @stdcomb.to_s + data
|
489
|
+
|
490
|
+
if block_given?
|
491
|
+
result = block.call(for_stdout, :stdout)
|
492
|
+
if result && result.is_a?(String)
|
493
|
+
@channel.send_data(result + "\n") if @channel
|
494
|
+
end
|
495
|
+
end
|
496
|
+
end
|
497
|
+
|
498
|
+
def append_stderr(data, &block)
|
499
|
+
data = data.gsub("\r\n", "\n").gsub(" \r", '').gsub("\r", @options[:replace_cr])
|
500
|
+
|
501
|
+
@stderr = @stderr.to_s + data
|
502
|
+
@stdcomb = @stdcomb.to_s + data
|
503
|
+
|
504
|
+
if block_given?
|
505
|
+
result = block.call(data, :stderr)
|
506
|
+
if result && result.is_a?(String)
|
507
|
+
@channel.send_data(result + "\n") if @channel
|
508
|
+
end
|
509
|
+
end
|
510
|
+
end
|
511
|
+
|
512
|
+
def buffer_input(&block)
|
513
|
+
raise ConnectionClosed.new('Connection is closed.') unless @channel
|
514
|
+
block ||= Proc.new { }
|
515
|
+
|
516
|
+
@last_input = Time.now
|
517
|
+
|
518
|
+
@channel.on_data do |_, data|
|
519
|
+
append_stdout strip_ansi_escape(sterilize(data)), &block
|
520
|
+
end
|
521
|
+
|
522
|
+
@channel.on_extended_data do |_, type, data|
|
523
|
+
if type == 1
|
524
|
+
append_stderr strip_ansi_escape(sterilize(data)), &block
|
525
|
+
end
|
526
|
+
end
|
527
|
+
|
528
|
+
end
|
529
|
+
|
530
|
+
def wait_for_prompt
|
531
|
+
raise ConnectionClosed.new('Connection is closed.') unless @channel
|
532
|
+
|
533
|
+
wait_timeout = @options[:silence_wait].to_s.to_i
|
534
|
+
@last_input ||= Time.now
|
535
|
+
sent_nl_at = nil
|
536
|
+
sent_nl_times = 0
|
537
|
+
|
538
|
+
@channel.connection.loop do
|
539
|
+
# cache the last input, this way if something is received it doesn't screw with us.
|
540
|
+
last_input = @last_input
|
541
|
+
|
542
|
+
# do we need to nudge the shell?
|
543
|
+
if wait_timeout > 0 && (Time.now - last_input) > wait_timeout
|
544
|
+
|
545
|
+
# have we nudged the shell more than twice?
|
546
|
+
if sent_nl_times > 2
|
547
|
+
raise LongSilence.new('No input from shell for extended period.')
|
548
|
+
else
|
549
|
+
|
550
|
+
# reset the timer and increment the counter if the timer hasn't budged since we last nudged it.
|
551
|
+
sent_nl_times = (sent_nl_at.nil? || sent_nl_at < last_input) ? 1 : (sent_nl_times + 1)
|
552
|
+
sent_nl_at = Time.now
|
553
|
+
|
554
|
+
# and send the NL to nudge along the shell.
|
555
|
+
@channel.send_data "\n"
|
556
|
+
@last_input = sent_nl_at
|
557
|
+
end
|
558
|
+
end
|
559
|
+
|
560
|
+
!prompted?
|
561
|
+
end
|
562
|
+
|
563
|
+
reset_prompted
|
564
|
+
end
|
565
|
+
|
566
|
+
def sterilize(data)
|
567
|
+
if @options[:filter_password]
|
568
|
+
spwd = '*' * @options[:password].length
|
569
|
+
data.gsub(@options[:password], spwd)
|
570
|
+
else
|
571
|
+
data
|
572
|
+
end
|
573
|
+
end
|
574
|
+
|
575
|
+
def strip_ansi_escape(data)
|
576
|
+
data
|
577
|
+
.gsub(/\e\[(\d+;?)*[ABCDEFGHfu]/, "\n") # any of the "set cursor position" CSI commands.
|
578
|
+
.gsub(/\e\[=?(\d+;?)*[A-Za-z]/,'') # \e[#;#;#A or \e[=#;#;#A basically all the CSI commands except ...
|
579
|
+
.gsub(/\e\[(\d+;"[^"]+";?)+p/, '') # \e[#;"A"p
|
580
|
+
.gsub(/\e[NOc]./,'?') # any of the alternate character set commands.
|
581
|
+
.gsub(/\e[P_\]^X][^\e\a]*(\a|(\e\\))/,'') # any string command
|
582
|
+
.gsub(/[\x00\x08\x0B\x0C\x0E-\x1F]/, '') # any non-printable characters (notice \x0A (LF) and \x0D (CR) are left as is).
|
583
|
+
.gsub("\t", ' ') # turn tabs into spaces.
|
584
|
+
end
|
585
|
+
|
586
|
+
def sftp
|
587
|
+
raise ConnectionClosed.new('Connection is closed.') unless @ssh
|
588
|
+
@sftp ||= ::Net::SFTP.start(@options[:host], @options[:user], password: @options[:password])
|
589
|
+
end
|
590
|
+
|
591
|
+
end
|
592
|
+
end
|
metadata
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: barkest_ssh
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.1.13
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Beau Barker
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-12-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: net-ssh
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 3.0.2
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 3.0.2
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: net-sftp
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 2.1.2
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 2.1.2
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.12'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.12'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '10.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '10.0'
|
69
|
+
description:
|
70
|
+
email:
|
71
|
+
- beau@barkerest.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- ".gitignore"
|
77
|
+
- Gemfile
|
78
|
+
- LICENSE.txt
|
79
|
+
- README.md
|
80
|
+
- Rakefile
|
81
|
+
- barkest_ssh.gemspec
|
82
|
+
- bin/console
|
83
|
+
- bin/setup
|
84
|
+
- lib/barkest_ssh.rb
|
85
|
+
- lib/barkest_ssh/secure_shell.rb
|
86
|
+
- lib/barkest_ssh/version.rb
|
87
|
+
homepage: http://www.barkerest.com/
|
88
|
+
licenses:
|
89
|
+
- MIT
|
90
|
+
metadata: {}
|
91
|
+
post_install_message:
|
92
|
+
rdoc_options: []
|
93
|
+
require_paths:
|
94
|
+
- lib
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '0'
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '0'
|
105
|
+
requirements: []
|
106
|
+
rubyforge_project:
|
107
|
+
rubygems_version: 2.4.5.1
|
108
|
+
signing_key:
|
109
|
+
specification_version: 4
|
110
|
+
summary: Provides a very simple interface to Net::SSH.
|
111
|
+
test_files: []
|