em-ssh 0.0.1
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.
- data/README.md +102 -0
- data/bin/em-ssh +102 -0
- data/bin/em-ssh-shell +73 -0
- data/lib/em-ssh/authentication-session.rb +38 -0
- data/lib/em-ssh/callbacks.rb +99 -0
- data/lib/em-ssh/connection.rb +277 -0
- data/lib/em-ssh/log.rb +30 -0
- data/lib/em-ssh/packet-stream.rb +154 -0
- data/lib/em-ssh/server-version.rb +37 -0
- data/lib/em-ssh/session.rb +51 -0
- data/lib/em-ssh/shell.rb +225 -0
- data/lib/em-ssh/version.rb +5 -0
- data/lib/em-ssh.rb +74 -0
- metadata +106 -0
data/README.md
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
#EM-SSH
|
2
|
+
Em-ssh is a net-ssh adapter for EventMachine. For the most part you can take any net-ssh code you have and run it in the EventMachine reactor.
|
3
|
+
|
4
|
+
Em-ssh is not associated with the Jamis Buck's [net-ssh](http://net-ssh.github.com/) library. Please report any bugs with em-ssh to [https://github.com/simulacre/em-ssh/issues](https://github.com/simulacre/em-ssh/issues)
|
5
|
+
##Installation
|
6
|
+
gem install em-ssh
|
7
|
+
|
8
|
+
##Synopsis
|
9
|
+
EM.run do
|
10
|
+
EM::Ssh.start(host, user, :password => password) do |ssh|
|
11
|
+
# capture all stderr and stdout output from a remote process
|
12
|
+
ssh.exec!('uname -a').tap {|r| puts "\nuname: #{r}"}
|
13
|
+
|
14
|
+
# capture only stdout matching a particular pattern
|
15
|
+
stdout = ""
|
16
|
+
ssh.exec!("ls -l /home") do |channel, stream, data|
|
17
|
+
stdout << data if stream == :stdout
|
18
|
+
end
|
19
|
+
puts "\n#{stdout}"
|
20
|
+
|
21
|
+
# run multiple processes in parallel to completion
|
22
|
+
ssh.exec('ping -c 1 www.google.com')
|
23
|
+
ssh.exec('ping -c 1 www.yahoo.com')
|
24
|
+
ssh.exec('ping -c 1 www.rakuten.co.jp')
|
25
|
+
|
26
|
+
#open a new channel and configure a minimal set of callbacks, then wait for the channel to finishes (closees).
|
27
|
+
channel = ssh.open_channel do |ch|
|
28
|
+
ch.exec "/usr/local/bin/ruby /path/to/file.rb" do |ch, success|
|
29
|
+
raise "could not execute command" unless success
|
30
|
+
|
31
|
+
# "on_data" is called when the process writes something to stdout
|
32
|
+
ch.on_data do |c, data|
|
33
|
+
$stdout.print data
|
34
|
+
end
|
35
|
+
|
36
|
+
# "on_extended_data" is called when the process writes something to stderr
|
37
|
+
ch.on_extended_data do |c, type, data|
|
38
|
+
$stderr.print data
|
39
|
+
end
|
40
|
+
|
41
|
+
ch.on_close { puts "done!" }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
channel.wait
|
46
|
+
|
47
|
+
ssh.close
|
48
|
+
EM.stop
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
See [http://net-ssh.github.com/ssh/v2/api/index.html](http://net-ssh.github.com/ssh/v2/api/index.html)
|
53
|
+
|
54
|
+
##Shell
|
55
|
+
|
56
|
+
Em-ssh provides an exepct-like shell abstraction layer on top of net-ssh in EM::Ssh::Shell
|
57
|
+
|
58
|
+
### Example
|
59
|
+
EM.run {
|
60
|
+
EM::Ssh::Shell.new(host, 'caleb', 'password') do |shell|
|
61
|
+
shell.wait_for(']$')
|
62
|
+
shell.send_and_wait('sudo su -', 'password for caleb: ')
|
63
|
+
shell.send_and_wait('password', ']$')
|
64
|
+
output = shell.send_and_wait('/etc/init.d/openvpn restart', ']$')
|
65
|
+
# ...
|
66
|
+
shell.send_and_wait('exit', ']$')
|
67
|
+
shell.send_data('exit')
|
68
|
+
end
|
69
|
+
}
|
70
|
+
|
71
|
+
|
72
|
+
|
73
|
+
## Other Examples
|
74
|
+
See bin/em-ssh for an example of a basic replacement for system ssh.
|
75
|
+
|
76
|
+
See bin/em-ssh-shell for a more complex example usage of Shell.
|
77
|
+
|
78
|
+
|
79
|
+
##Copyright
|
80
|
+
Copyright (c) 2011 Caleb Crane
|
81
|
+
|
82
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
83
|
+
a copy of this software and associated documentation files (the
|
84
|
+
"Software"), to deal in the Software without restriction, including
|
85
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
86
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
87
|
+
permit persons to whom the Software is furnished to do so, subject to
|
88
|
+
the following conditions:
|
89
|
+
|
90
|
+
The above copyright notice and this permission notice shall be
|
91
|
+
included in all copies or substantial portions of the Software.
|
92
|
+
|
93
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
94
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
95
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
96
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
97
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
98
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
99
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
100
|
+
|
101
|
+
|
102
|
+
Portions of this software are Copyright (c) 2008 Jamis Buck
|
data/bin/em-ssh
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# This is really nothing more than a utility to test the em-ssh adapter.
|
3
|
+
# It's not meant to be used for anything else.
|
4
|
+
# It probably requires ruby 1.9.2-p180 as p190 tends to segfault when using Fibers.
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'termios'
|
7
|
+
require 'highline'
|
8
|
+
require 'em-ssh'
|
9
|
+
|
10
|
+
include EM::Ssh::Log
|
11
|
+
|
12
|
+
|
13
|
+
def bufferio( enable, io = $stdin )
|
14
|
+
raise "Termios library not found" unless defined?(::Termios)
|
15
|
+
attr = Termios::getattr( io )
|
16
|
+
enable ? (attr.c_lflag |= Termios::ICANON | Termios::ECHO) : (attr.c_lflag &= ~(Termios::ICANON|Termios::ECHO))
|
17
|
+
Termios::setattr( io, Termios::TCSANOW, attr )
|
18
|
+
end # def bufferio( enable, io = $stdin )
|
19
|
+
|
20
|
+
def abort(msg)
|
21
|
+
puts msg
|
22
|
+
Process.exit
|
23
|
+
end # abort(msg)
|
24
|
+
|
25
|
+
options = {}
|
26
|
+
opts = OptionParser.new
|
27
|
+
opts.banner += " [user:[password]@]host[:port]"
|
28
|
+
options[:port] = 22
|
29
|
+
opts.on('-u', '--user String', String) { |u| options[:user] = u }
|
30
|
+
opts.on('-p', '--password [String]', String) do |p|
|
31
|
+
options[:password] = p.nil? ? HighLine.new.ask("password: "){|q| q.echo = "*" } : p
|
32
|
+
end
|
33
|
+
opts.on('-v', '--verbose') do
|
34
|
+
EM::Ssh.logger.level = EM::Ssh.logger.level - 1 unless EM::Ssh.logger.level == 0
|
35
|
+
options[:verbose] = EM::Ssh.logger.level
|
36
|
+
end
|
37
|
+
opts.parse!
|
38
|
+
|
39
|
+
host = ARGV.shift
|
40
|
+
if host.nil?
|
41
|
+
host,options[:password] = options[:password], HighLine.new.ask("#{options[:password]}'s password: "){|q| q.echo = "*" }
|
42
|
+
end # host.nil?
|
43
|
+
abort("a host is required") if host.nil?
|
44
|
+
|
45
|
+
options[:user], host = *host.split('@') if host.include?('@')
|
46
|
+
options[:user], options[:password] = *options[:user].split(':') if options[:user].include?(':')
|
47
|
+
host, options[:port] = *host.split(':') if host.include?(':')
|
48
|
+
connected = false
|
49
|
+
|
50
|
+
|
51
|
+
module CInput
|
52
|
+
def shell=(shell)
|
53
|
+
@shell = shell
|
54
|
+
end # shell=(shell)
|
55
|
+
def initialize
|
56
|
+
bufferio(false, $stdin)
|
57
|
+
end # initialize
|
58
|
+
def unbind
|
59
|
+
bufferio(true, $stdin)
|
60
|
+
end # unbind
|
61
|
+
def notify_readable
|
62
|
+
@shell.send_data($stdin.read(1))
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
|
68
|
+
EM.run do
|
69
|
+
EM::Ssh.start(host, options[:user], options) do |connection|
|
70
|
+
debug "**** connected: #{connection}"
|
71
|
+
connection.open_channel do |channel|
|
72
|
+
debug "**** channel: #{channel}"
|
73
|
+
channel.request_pty(options[:pty] || {}) do |pty,suc|
|
74
|
+
debug "***** pty: #{pty}; suc: #{suc}"
|
75
|
+
pty.send_channel_request("shell") do |shell,success|
|
76
|
+
raise ConnectionError, "Failed to create shell." unless success
|
77
|
+
debug "***** shell: #{shell}"
|
78
|
+
connected = true
|
79
|
+
|
80
|
+
shell.on_data { |c,d| $stdout.print d }
|
81
|
+
shell.on_extended_data { |c,data| $STDERR.print data }
|
82
|
+
shell.on_eof do
|
83
|
+
shell.close
|
84
|
+
EM.stop
|
85
|
+
end #
|
86
|
+
|
87
|
+
trap("SIGINT") { shell.send_data("\C-c") }
|
88
|
+
trap("SIGEXIT") do
|
89
|
+
shell.close
|
90
|
+
trap("SIGINT", "SIG_DFL")
|
91
|
+
end #
|
92
|
+
|
93
|
+
conn = EM.watch($stdin, CInput)
|
94
|
+
conn.shell = shell
|
95
|
+
conn.notify_readable = true
|
96
|
+
|
97
|
+
end # |shell,success|
|
98
|
+
end # |pty,suc|
|
99
|
+
end # |channel|
|
100
|
+
end # |connection|
|
101
|
+
end # EM.start
|
102
|
+
|
data/bin/em-ssh-shell
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# This is really nothing more than a utility to test the em-ssh adapter.
|
3
|
+
# It's not meant to be used for anything else.
|
4
|
+
# It probably requires ruby 1.9.2-p180 as p190 tends to segfault when using Fibers.
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'termios'
|
7
|
+
require 'highline'
|
8
|
+
require 'em-ssh'
|
9
|
+
require 'em-ssh/shell'
|
10
|
+
|
11
|
+
include EM::Ssh::Log
|
12
|
+
|
13
|
+
def abort(msg)
|
14
|
+
puts msg
|
15
|
+
Process.exit
|
16
|
+
end # abort(msg)
|
17
|
+
|
18
|
+
options = {}
|
19
|
+
opts = OptionParser.new
|
20
|
+
opts.banner += " [user:[password]@]host[:port] wait_string command [command command ...]"
|
21
|
+
options[:port] = 22
|
22
|
+
opts.on('-u', '--user String', String) { |u| options[:user] = u }
|
23
|
+
opts.on('-p', '--password [String]', String) do |p|
|
24
|
+
options[:password] = p.nil? ? HighLine.new.ask("password: "){|q| q.echo = "*" } : p
|
25
|
+
end
|
26
|
+
opts.on('-v', '--verbose') do
|
27
|
+
EM::Ssh.logger.level = EM::Ssh.logger.level - 1 unless EM::Ssh.logger.level == 0
|
28
|
+
options[:verbose] = EM::Ssh.logger.level
|
29
|
+
end
|
30
|
+
opts.parse!
|
31
|
+
|
32
|
+
host = ARGV.shift
|
33
|
+
if host.nil?
|
34
|
+
host,options[:password] = options[:password], HighLine.new.ask("#{options[:password]}'s password: "){|q| q.echo = "*" }
|
35
|
+
end # host.nil?
|
36
|
+
abort("a host is required") if host.nil?
|
37
|
+
|
38
|
+
options[:user], host = *host.split('@') if host.include?('@')
|
39
|
+
options[:user], options[:password] = *options[:user].split(':') if options[:user].include?(':')
|
40
|
+
host, options[:port] = *host.split(':') if host.include?(':')
|
41
|
+
|
42
|
+
|
43
|
+
waitstr = ARGV.shift
|
44
|
+
commands = ARGV
|
45
|
+
abort("wait_string is required") if waitstr.nil?
|
46
|
+
abort("command is required") if commands.empty?
|
47
|
+
|
48
|
+
|
49
|
+
EM.run do
|
50
|
+
Fiber.new {
|
51
|
+
shell = EM::Ssh::Shell.new(host, options[:user], options[:password])
|
52
|
+
commands.each do |command|
|
53
|
+
mys = shell.split
|
54
|
+
mys.on(:closed) { info("#{mys} has closed") }
|
55
|
+
|
56
|
+
EM.next_tick do
|
57
|
+
Fiber.new {
|
58
|
+
debug("#{mys} waited for: '#{mys.wait_for(waitstr)}'")
|
59
|
+
mys.line_terminator = "\n"
|
60
|
+
debug("#{mys} send: #{command.inspect}")
|
61
|
+
puts "#{mys} result: '#{mys.send_and_wait(command, waitstr)}'"
|
62
|
+
mys.close
|
63
|
+
}.resume
|
64
|
+
end #
|
65
|
+
end # |command|
|
66
|
+
|
67
|
+
shell.on(:childless) do
|
68
|
+
info("#{shell}'s children all closed")
|
69
|
+
shell.close
|
70
|
+
EM.stop
|
71
|
+
end # :childless
|
72
|
+
}.resume
|
73
|
+
end # EM.run
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module EventMachine
|
2
|
+
class Ssh
|
3
|
+
class AuthenticationSession < Net::SSH::Authentication::Session
|
4
|
+
include Log
|
5
|
+
|
6
|
+
def authenticate(*args)
|
7
|
+
debug { "authenticate(#{args.join(", ")})" }
|
8
|
+
super(*args)
|
9
|
+
end # authenticate(*args)
|
10
|
+
|
11
|
+
# Returns once an acceptable auth packet is received.
|
12
|
+
def next_message
|
13
|
+
packet = transport.next_message
|
14
|
+
|
15
|
+
case packet.type
|
16
|
+
when USERAUTH_BANNER
|
17
|
+
info { packet[:message] }
|
18
|
+
transport.fire(:auth_banner, packet[:message])
|
19
|
+
return next_message
|
20
|
+
when USERAUTH_FAILURE
|
21
|
+
@allowed_auth_methods = packet[:authentications].split(/,/)
|
22
|
+
debug { "allowed methods: #{packet[:authentications]}" }
|
23
|
+
return packet
|
24
|
+
|
25
|
+
when USERAUTH_METHOD_RANGE, SERVICE_ACCEPT
|
26
|
+
return packet
|
27
|
+
|
28
|
+
when USERAUTH_SUCCESS
|
29
|
+
transport.hint :authenticated
|
30
|
+
return packet
|
31
|
+
|
32
|
+
else
|
33
|
+
raise SshError, "unexpected message #{packet.type} (#{packet})"
|
34
|
+
end
|
35
|
+
end # next_message
|
36
|
+
end # class::AuthenticationSession
|
37
|
+
end # module::Ssh
|
38
|
+
end # module::EventMachine
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module EventMachine
|
2
|
+
class Ssh
|
3
|
+
# A simple mixin enabling your objects to allow other objects to register callbacks and fire events.
|
4
|
+
# @example
|
5
|
+
# class Connection
|
6
|
+
# include Callbacks
|
7
|
+
# # ...
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# connection = Connection.new(...)
|
11
|
+
# cb = connection.on(:data) do |data|
|
12
|
+
# @version << data
|
13
|
+
# if @version[-1] == "\n"
|
14
|
+
# @version.chomp!
|
15
|
+
# raise SshError.new("incompatible SSH version `#{@version}'") unless @version.match(/^SSH-(1\.99|2\.0)-/)
|
16
|
+
# connection.send_data("#{PROTO_VERSION}\r\n")
|
17
|
+
# cb.cancel
|
18
|
+
# connection.fire(:version_negotiated)
|
19
|
+
# end # @header[-1] == "\n"
|
20
|
+
# end # |data|
|
21
|
+
module Callbacks
|
22
|
+
|
23
|
+
# @return [Hash] The registered callbacks
|
24
|
+
def callbacks
|
25
|
+
@clbks ||= {}
|
26
|
+
end # callbacks
|
27
|
+
|
28
|
+
|
29
|
+
# Signal that an event has occured.
|
30
|
+
# Each callback will receive whatever args are passed to fire, or the object that the event was fired upon.
|
31
|
+
# @param [Symbol] event
|
32
|
+
# @param [Objects] arguments to pass to all callbacks registered for the given event
|
33
|
+
# @param [Array] the results of each callback that was executed
|
34
|
+
def fire(event, *args)
|
35
|
+
#log.debug("#{self}.fire(#{event.inspect}, #{args})")
|
36
|
+
args = self if args.empty?
|
37
|
+
(callbacks[event] ||= []).clone.map { |cb| cb.call(*args) }
|
38
|
+
end # fire(event)
|
39
|
+
|
40
|
+
# Register a callback to be fired when a matching event occurs.
|
41
|
+
# The callback will be fired when the event occurs until it returns true.
|
42
|
+
# @param [Symbol] event
|
43
|
+
def on(event, &blk)
|
44
|
+
#log.debug("#{self}.on(#{event.inspect}, #{blk})")
|
45
|
+
if block_given?
|
46
|
+
raise "event (#{event.inspect}) must be a symbol when a block is given" unless event.is_a?(Symbol)
|
47
|
+
return Callback.new(self, event, &blk).tap{|cb| (callbacks[event] ||= []).push(cb) }
|
48
|
+
end # block_given?
|
49
|
+
|
50
|
+
raise "event (#{event.inspect}) must be a Callback when a block is not given" unless event.is_a?(Callback)
|
51
|
+
(callbacks[event] ||= []).push(event)
|
52
|
+
return event
|
53
|
+
end # on(event, &blk)
|
54
|
+
|
55
|
+
# Registers a callback that will be canceled after the first time it is called.
|
56
|
+
def on_next(event, &blk)
|
57
|
+
cb = on(event) do |*args|
|
58
|
+
cb.cancel
|
59
|
+
blk.call(*args)
|
60
|
+
end # |*args|
|
61
|
+
end # on_next(event, &blk)
|
62
|
+
|
63
|
+
|
64
|
+
class Callback
|
65
|
+
# The object that keeps this callback
|
66
|
+
attr_reader :obj
|
67
|
+
# [Sybmol] the name of the event
|
68
|
+
attr_reader :event
|
69
|
+
# The block to call when the event is fired
|
70
|
+
attr_reader :block
|
71
|
+
|
72
|
+
def initialize(obj, event, &blk)
|
73
|
+
raise ArgumentError.new("a block is required") unless block_given?
|
74
|
+
@obj = obj
|
75
|
+
@event = event
|
76
|
+
@block = blk
|
77
|
+
end # initialize(obj, event, &blk)
|
78
|
+
|
79
|
+
# Call the callback with optional arguments
|
80
|
+
def call(*args)
|
81
|
+
block.call(*args)
|
82
|
+
end # call(*args)
|
83
|
+
|
84
|
+
# Registers the callback with the object.
|
85
|
+
# This is useful if you cancel the callback at one point and want to re-enable it later on.
|
86
|
+
def register
|
87
|
+
@obj.on(self)
|
88
|
+
end # register
|
89
|
+
|
90
|
+
def cancel
|
91
|
+
raise "#{@obj} does not have any callbacks for #{@event.inspect}" unless @obj.respond_to?(:callbacks) && @obj.callbacks.respond_to?(:[]) && @obj.callbacks[@event].respond_to?(:delete)
|
92
|
+
@obj.callbacks[@event].delete(self)
|
93
|
+
self
|
94
|
+
end # cancel
|
95
|
+
end # class::Callback
|
96
|
+
|
97
|
+
end # module::Callbacks
|
98
|
+
end # class::Ssh
|
99
|
+
end # module::EventMachine
|
@@ -0,0 +1,277 @@
|
|
1
|
+
require 'em-ssh/callbacks'
|
2
|
+
module EventMachine
|
3
|
+
class Ssh
|
4
|
+
# EventMachine::Ssh::Connection is a EventMachine::Connection that emulates the Net::SSH transport layer. It ties
|
5
|
+
# itself into Net::SSH so that the EventMachine reactor loop can take the place of the Net::SSH event loop.
|
6
|
+
# Most of the methods here are only for compatibility with Net::SSH
|
7
|
+
class Connection < EventMachine::Connection
|
8
|
+
include Log
|
9
|
+
|
10
|
+
# Allows other objects to register callbacks with events that occur on a Ssh instance
|
11
|
+
include Callbacks
|
12
|
+
|
13
|
+
##
|
14
|
+
# Transport related
|
15
|
+
|
16
|
+
# @return [String] The host to connect to, as given to the constructor.
|
17
|
+
attr_reader :host
|
18
|
+
|
19
|
+
# @return [Fixnum] the port number (DEFAULT_PORT) to connect to, as given in the options to the constructor.
|
20
|
+
attr_reader :port
|
21
|
+
|
22
|
+
# @return [ServerVersion] The ServerVersion instance that encapsulates the negotiated protocol version.
|
23
|
+
attr_reader :server_version
|
24
|
+
|
25
|
+
# The Algorithms instance used to perform key exchanges.
|
26
|
+
attr_reader :algorithms
|
27
|
+
|
28
|
+
# The host-key verifier object used to verify host keys, to ensure that the connection is not being spoofed.
|
29
|
+
attr_reader :host_key_verifier
|
30
|
+
|
31
|
+
# The hash of options that were given to the object at initialization.
|
32
|
+
attr_reader :options
|
33
|
+
|
34
|
+
# @return [PacketStream] emulates a socket and ssh packetstream
|
35
|
+
attr_reader :socket
|
36
|
+
|
37
|
+
# @return [Boolean] true if the connection has been closed
|
38
|
+
def closed?
|
39
|
+
@closed == true
|
40
|
+
end
|
41
|
+
|
42
|
+
# Close the connection
|
43
|
+
def close
|
44
|
+
# #unbind will update @closed
|
45
|
+
close_connection
|
46
|
+
end
|
47
|
+
|
48
|
+
# Send a packet to the server
|
49
|
+
def send_message(message)
|
50
|
+
@socket.send_packet(message)
|
51
|
+
end
|
52
|
+
alias :enqueue_message :send_message
|
53
|
+
|
54
|
+
def next_message
|
55
|
+
return @queue.shift if @queue.any? && algorithms.allow?(@queue.first)
|
56
|
+
f = Fiber.current
|
57
|
+
cb = on(:packet) do |packet|
|
58
|
+
if @queue.any? && algorithms.allow?(@queue.first)
|
59
|
+
cb.cancel
|
60
|
+
f.resume(@queue.shift)
|
61
|
+
end
|
62
|
+
end # :packet
|
63
|
+
return Fiber.yield
|
64
|
+
end # next_message
|
65
|
+
|
66
|
+
# Returns a new service_request packet for the given service name, ready
|
67
|
+
# for sending to the server.
|
68
|
+
def service_request(service)
|
69
|
+
Net::SSH::Buffer.from(:byte, SERVICE_REQUEST, :string, service)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Requests a rekey operation, and simulates a block until the operation completes.
|
73
|
+
# If a rekey is already pending, this returns immediately, having no effect.
|
74
|
+
def rekey!
|
75
|
+
if !algorithms.pending?
|
76
|
+
f = Fiber.current
|
77
|
+
on_next(:algo_init) do
|
78
|
+
f.resume
|
79
|
+
end # :algo_init
|
80
|
+
algorithms.rekey!
|
81
|
+
return Fiber.yield
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns immediately if a rekey is already in process. Otherwise, if a
|
86
|
+
# rekey is needed (as indicated by the socket, see PacketStream#if_needs_rekey?)
|
87
|
+
# one is performed, causing this method to block until it completes.
|
88
|
+
def rekey_as_needed
|
89
|
+
return if algorithms.pending?
|
90
|
+
socket.if_needs_rekey? { rekey! }
|
91
|
+
end
|
92
|
+
|
93
|
+
|
94
|
+
|
95
|
+
##
|
96
|
+
# EventMachine callbacks
|
97
|
+
###
|
98
|
+
def post_init
|
99
|
+
@socket = PacketStream.new(self)
|
100
|
+
@data = @socket.input
|
101
|
+
end # post_init
|
102
|
+
|
103
|
+
# @return
|
104
|
+
def unbind
|
105
|
+
debug("#{self} is unbound")
|
106
|
+
@closed = true
|
107
|
+
end
|
108
|
+
|
109
|
+
def receive_data(data)
|
110
|
+
debug("read #{data.length} bytes")
|
111
|
+
@data.append(data)
|
112
|
+
fire(:data, data)
|
113
|
+
end
|
114
|
+
|
115
|
+
|
116
|
+
|
117
|
+
def initialize(options = {})
|
118
|
+
debug("#{self.class}.new(#{options})")
|
119
|
+
@host = options[:host]
|
120
|
+
@port = options[:port]
|
121
|
+
@password = options[:password]
|
122
|
+
@queue = []
|
123
|
+
@options = options
|
124
|
+
|
125
|
+
begin
|
126
|
+
on(:connected, &options[:callback]) if options[:callback]
|
127
|
+
@error_callback = lambda { |code| raise SshError.new(code) }
|
128
|
+
|
129
|
+
@host_key_verifier = select_host_key_verifier(options[:paranoid])
|
130
|
+
@server_version = ServerVersion.new(self)
|
131
|
+
on(:version_negotiated) do
|
132
|
+
@data.consume!(@server_version.header.length)
|
133
|
+
@algorithms = Net::SSH::Transport::Algorithms.new(self, options)
|
134
|
+
|
135
|
+
register_data_handler
|
136
|
+
|
137
|
+
on_next(:algo_init) do
|
138
|
+
auth = AuthenticationSession.new(self, options)
|
139
|
+
user = options.fetch(:user, user)
|
140
|
+
Fiber.new do
|
141
|
+
if auth.authenticate("ssh-connection", user, options[:password])
|
142
|
+
fire(:connected, Session.new(self, options))
|
143
|
+
else
|
144
|
+
close_connection
|
145
|
+
raise Net::SSH::AuthenticationFailed, user
|
146
|
+
end # auth.authenticate("ssh-connection", user, options[:password])
|
147
|
+
end.resume # Fiber
|
148
|
+
end # :algo_init
|
149
|
+
end # :version_negotiated
|
150
|
+
|
151
|
+
rescue Exception => e
|
152
|
+
log.fatal("caught an error during initialization: #{e}\n #{e.backtrace.join("\n ")}")
|
153
|
+
Process.exit
|
154
|
+
end # begin
|
155
|
+
self
|
156
|
+
end # initialize(options = {})
|
157
|
+
|
158
|
+
|
159
|
+
##
|
160
|
+
# Helpers required for compatibility with Net::SSH
|
161
|
+
##
|
162
|
+
|
163
|
+
# Returns the host (and possibly IP address) in a format compatible with
|
164
|
+
# SSH known-host files.
|
165
|
+
def host_as_string
|
166
|
+
@host_as_string ||= "#{host}".tap do |string|
|
167
|
+
string = "[#{string}]:#{port}" if port != DEFAULT_PORT
|
168
|
+
_, ip = Socket.unpack_sockaddr_in(get_peername)
|
169
|
+
if ip != host
|
170
|
+
string << "," << (port != DEFAULT_PORT ? "[#{ip}]:#{port}" : ip)
|
171
|
+
end # ip != host
|
172
|
+
end # |string|
|
173
|
+
end # host_as_string
|
174
|
+
|
175
|
+
alias :logger :log
|
176
|
+
|
177
|
+
|
178
|
+
# Configure's the packet stream's client state with the given set of
|
179
|
+
# options. This is typically used to define the cipher, compression, and
|
180
|
+
# hmac algorithms to use when sending packets to the server.
|
181
|
+
def configure_client(options={})
|
182
|
+
@socket.client.set(options)
|
183
|
+
end
|
184
|
+
|
185
|
+
# Configure's the packet stream's server state with the given set of
|
186
|
+
# options. This is typically used to define the cipher, compression, and
|
187
|
+
# hmac algorithms to use when reading packets from the server.
|
188
|
+
def configure_server(options={})
|
189
|
+
@socket.server.set(options)
|
190
|
+
end
|
191
|
+
|
192
|
+
# Sets a new hint for the packet stream, which the packet stream may use
|
193
|
+
# to change its behavior. (See PacketStream#hints).
|
194
|
+
def hint(which, value=true)
|
195
|
+
@socket.hints[which] = value
|
196
|
+
end
|
197
|
+
|
198
|
+
# Returns a new service_request packet for the given service name, ready
|
199
|
+
# for sending to the server.
|
200
|
+
def service_request(service)
|
201
|
+
Net::SSH::Buffer.from(:byte, SERVICE_REQUEST, :string, service)
|
202
|
+
end
|
203
|
+
|
204
|
+
# Returns a hash of information about the peer (remote) side of the socket,
|
205
|
+
# including :ip, :port, :host, and :canonized (see #host_as_string).
|
206
|
+
def peer
|
207
|
+
@peer ||= {}.tap do |p|
|
208
|
+
_, ip = Socket.unpack_sockaddr_in(get_peername)
|
209
|
+
p[:ip] = ip
|
210
|
+
p[:port] = @port.to_i
|
211
|
+
p[:host] = @host
|
212
|
+
p[:canonized] = host_as_string
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
|
217
|
+
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
# Register the primary :data callback
|
222
|
+
# @return [Callback] the callback that was registered
|
223
|
+
def register_data_handler
|
224
|
+
on(:data) do |data|
|
225
|
+
# @todo instead of while use a EM.next_tick
|
226
|
+
while (packet = @socket.poll_next_packet)
|
227
|
+
case packet.type
|
228
|
+
when DISCONNECT
|
229
|
+
raise Net::SSH::Disconnect, "disconnected: #{packet[:description]} (#{packet[:reason_code]})"
|
230
|
+
when IGNORE
|
231
|
+
debug("IGNORE packet received: #{packet[:data].inspect}")
|
232
|
+
when UNIMPLEMENTED
|
233
|
+
log.warn("UNIMPLEMENTED: #{packet[:number]}")
|
234
|
+
when DEBUG
|
235
|
+
log.send((packet[:always_display] ? :fatal : :debug), packet[:message])
|
236
|
+
when KEXINIT
|
237
|
+
Fiber.new do
|
238
|
+
algorithms.accept_kexinit(packet)
|
239
|
+
fire(:algo_init) if algorithms.initialized?
|
240
|
+
end.resume
|
241
|
+
else
|
242
|
+
@queue.push(packet)
|
243
|
+
if algorithms.allow?(packet)
|
244
|
+
fire(:packet, packet)
|
245
|
+
fire(:session_packet, packet) if packet.type >= GLOBAL_REQUEST
|
246
|
+
end # algorithms.allow?(packet)
|
247
|
+
socket.consume!
|
248
|
+
end # packet.type
|
249
|
+
end # (packet = @socket.poll_next_packet)
|
250
|
+
end # |data|
|
251
|
+
end # register_data_handler
|
252
|
+
|
253
|
+
# Instantiates a new host-key verification class, based on the value of
|
254
|
+
# the parameter. When true or nil, the default Lenient verifier is
|
255
|
+
# returned. If it is false, the Null verifier is returned, and if it is
|
256
|
+
# :very, the Strict verifier is returned. If the argument happens to
|
257
|
+
# respond to :verify, it is returned directly. Otherwise, an exception
|
258
|
+
# is raised.
|
259
|
+
# Taken from Net::SSH::Session
|
260
|
+
def select_host_key_verifier(paranoid)
|
261
|
+
case paranoid
|
262
|
+
when true, nil then
|
263
|
+
Net::SSH::Verifiers::Lenient.new
|
264
|
+
when false then
|
265
|
+
Net::SSH::Verifiers::Null.new
|
266
|
+
when :very then
|
267
|
+
Net::SSH::Verifiers::Strict.new
|
268
|
+
else
|
269
|
+
paranoid.respond_to?(:verify) ? paranoid : (raise ArgumentError.new("argument to :paranoid is not valid: #{paranoid.inspect}"))
|
270
|
+
end # paranoid
|
271
|
+
end # select_host_key_verifier(paranoid)
|
272
|
+
|
273
|
+
end # class::Connection < EventMachine::Connection
|
274
|
+
end # module::Ssh
|
275
|
+
end # module::EventMachine
|
276
|
+
|
277
|
+
|
data/lib/em-ssh/log.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
module EventMachine
|
2
|
+
class Ssh
|
3
|
+
module Log
|
4
|
+
# @return [Logger] the default logger
|
5
|
+
def log
|
6
|
+
EventMachine::Ssh.logger
|
7
|
+
end
|
8
|
+
|
9
|
+
def debug(msg = nil, &blk)
|
10
|
+
log.debug("#{self.class}".downcase.gsub("::",".") + " #{msg}", &blk)
|
11
|
+
end
|
12
|
+
|
13
|
+
def info(msg = nil, &blk)
|
14
|
+
log.info("#{self.class}".downcase.gsub("::",".") + " #{msg}", &blk)
|
15
|
+
end
|
16
|
+
|
17
|
+
def fatal(msg = nil, &blk)
|
18
|
+
log.fatal("#{self.class}".downcase.gsub("::",".") + " #{msg}", &blk)
|
19
|
+
end
|
20
|
+
|
21
|
+
def warn(msg = nil, &blk)
|
22
|
+
log.warn("#{self.class}".downcase.gsub("::",".") + " #{msg}", &blk)
|
23
|
+
end
|
24
|
+
|
25
|
+
def error(msg = nil, &blk)
|
26
|
+
log.error("#{self.class}".downcase.gsub("::",".") + " #{msg}", &blk)
|
27
|
+
end
|
28
|
+
end # module::Log
|
29
|
+
end # class::Ssh
|
30
|
+
end # module::EventMachine
|
@@ -0,0 +1,154 @@
|
|
1
|
+
module EventMachine
|
2
|
+
class Ssh
|
3
|
+
class PacketStream
|
4
|
+
include Net::SSH::BufferedIo
|
5
|
+
include Log
|
6
|
+
|
7
|
+
|
8
|
+
# The map of "hints" that can be used to modify the behavior of the packet
|
9
|
+
# stream. For instance, when authentication succeeds, an "authenticated"
|
10
|
+
# hint is set, which is used to determine whether or not to compress the
|
11
|
+
# data when using the "delayed" compression algorithm.
|
12
|
+
attr_reader :hints
|
13
|
+
|
14
|
+
# The server state object, which encapsulates the algorithms used to interpret
|
15
|
+
# packets coming from the server.
|
16
|
+
attr_reader :server
|
17
|
+
|
18
|
+
# The client state object, which encapsulates the algorithms used to build
|
19
|
+
# packets to send to the server.
|
20
|
+
attr_reader :client
|
21
|
+
|
22
|
+
# The input stream
|
23
|
+
attr_reader :input
|
24
|
+
# The output stream
|
25
|
+
attr_reader :output
|
26
|
+
|
27
|
+
def initialize(connection)
|
28
|
+
@connection = connection
|
29
|
+
@input = Net::SSH::Buffer.new
|
30
|
+
@output = Net::SSH::Buffer.new
|
31
|
+
@hints = {}
|
32
|
+
@server = Net::SSH::Transport::State.new(self, :server)
|
33
|
+
@client = Net::SSH::Transport::State.new(self, :client)
|
34
|
+
@packet = nil
|
35
|
+
end # initialize(content="")
|
36
|
+
|
37
|
+
# Consumes n bytes from the buffer, where n is the current position
|
38
|
+
# unless otherwise specified. This is useful for removing data from the
|
39
|
+
# buffer that has previously been read, when you are expecting more data
|
40
|
+
# to be appended. It helps to keep the size of buffers down when they
|
41
|
+
# would otherwise tend to grow without bound.
|
42
|
+
#
|
43
|
+
# Returns the buffer object itself.
|
44
|
+
def consume!(*args)
|
45
|
+
input.consume!(*args)
|
46
|
+
end # consume!(*args)
|
47
|
+
|
48
|
+
# Tries to read the next packet. If there is insufficient data to read
|
49
|
+
# an entire packet, this returns immediately, otherwise the packet is
|
50
|
+
# read, post-processed according to the cipher, hmac, and compression
|
51
|
+
# algorithms specified in the server state object, and returned as a
|
52
|
+
# new Packet object.
|
53
|
+
# Copyright (c) 2008 Jamis Buck
|
54
|
+
def poll_next_packet
|
55
|
+
if @packet.nil?
|
56
|
+
minimum = server.block_size < 4 ? 4 : server.block_size
|
57
|
+
return nil if available < minimum
|
58
|
+
data = read_available(minimum)
|
59
|
+
# decipher it
|
60
|
+
@packet = Net::SSH::Buffer.new(server.update_cipher(data))
|
61
|
+
@packet_length = @packet.read_long
|
62
|
+
end
|
63
|
+
need = @packet_length + 4 - server.block_size
|
64
|
+
raise SshError, "padding error, need #{need} block #{server.block_size}" if need % server.block_size != 0
|
65
|
+
|
66
|
+
return nil if available < need + server.hmac.mac_length
|
67
|
+
|
68
|
+
if need > 0
|
69
|
+
# read the remainder of the packet and decrypt it.
|
70
|
+
data = read_available(need)
|
71
|
+
@packet.append(server.update_cipher(data))
|
72
|
+
end
|
73
|
+
|
74
|
+
# get the hmac from the tail of the packet (if one exists), and
|
75
|
+
# then validate it.
|
76
|
+
real_hmac = read_available(server.hmac.mac_length) || ""
|
77
|
+
|
78
|
+
@packet.append(server.final_cipher)
|
79
|
+
padding_length = @packet.read_byte
|
80
|
+
|
81
|
+
payload = @packet.read(@packet_length - padding_length - 1)
|
82
|
+
padding = @packet.read(padding_length) if padding_length > 0
|
83
|
+
|
84
|
+
my_computed_hmac = server.hmac.digest([server.sequence_number, @packet.content].pack("NA*"))
|
85
|
+
raise Net::SSH::Exception, "corrupted mac detected" if real_hmac != my_computed_hmac
|
86
|
+
|
87
|
+
# try to decompress the payload, in case compression is active
|
88
|
+
payload = server.decompress(payload)
|
89
|
+
|
90
|
+
log.debug("received packet nr #{server.sequence_number} type #{payload.getbyte(0)} len #{@packet_length}")
|
91
|
+
|
92
|
+
server.increment(@packet_length)
|
93
|
+
@packet = nil
|
94
|
+
|
95
|
+
return Net::SSH::Packet.new(payload)
|
96
|
+
end # poll_next_packet
|
97
|
+
|
98
|
+
# Copyright (c) 2008 Jamis Buck
|
99
|
+
def send_packet(payload)
|
100
|
+
# try to compress the packet
|
101
|
+
payload = client.compress(payload)
|
102
|
+
|
103
|
+
# the length of the packet, minus the padding
|
104
|
+
actual_length = 4 + payload.length + 1
|
105
|
+
|
106
|
+
# compute the padding length
|
107
|
+
padding_length = client.block_size - (actual_length % client.block_size)
|
108
|
+
padding_length += client.block_size if padding_length < 4
|
109
|
+
|
110
|
+
# compute the packet length (sans the length field itself)
|
111
|
+
packet_length = payload.length + padding_length + 1
|
112
|
+
if packet_length < 16
|
113
|
+
padding_length += client.block_size
|
114
|
+
packet_length = payload.length + padding_length + 1
|
115
|
+
end
|
116
|
+
|
117
|
+
padding = Array.new(padding_length) { rand(256) }.pack("C*")
|
118
|
+
|
119
|
+
unencrypted_data = [packet_length, padding_length, payload, padding].pack("NCA*A*")
|
120
|
+
mac = client.hmac.digest([client.sequence_number, unencrypted_data].pack("NA*"))
|
121
|
+
|
122
|
+
encrypted_data = client.update_cipher(unencrypted_data) << client.final_cipher
|
123
|
+
message = encrypted_data + mac
|
124
|
+
|
125
|
+
log.debug("queueing packet nr #{client.sequence_number} type #{payload.getbyte(0)} len #{packet_length}")
|
126
|
+
@connection.send_data(message)
|
127
|
+
log.debug("sent #{message.length} bytes")
|
128
|
+
client.increment(packet_length)
|
129
|
+
|
130
|
+
self
|
131
|
+
end # send_packet(payload)
|
132
|
+
|
133
|
+
# Performs any pending cleanup necessary on the IO and its associated
|
134
|
+
# state objects. (See State#cleanup).
|
135
|
+
def cleanup
|
136
|
+
client.cleanup
|
137
|
+
server.cleanup
|
138
|
+
end
|
139
|
+
|
140
|
+
# If the IO object requires a rekey operation (as indicated by either its
|
141
|
+
# client or server state objects, see State#needs_rekey?), this will
|
142
|
+
# yield. Otherwise, this does nothing.
|
143
|
+
# Copyright (c) 2008 Jamis Buck
|
144
|
+
def if_needs_rekey?
|
145
|
+
if client.needs_rekey? || server.needs_rekey?
|
146
|
+
yield
|
147
|
+
client.reset! if client.needs_rekey?
|
148
|
+
server.reset! if server.needs_rekey?
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
end # class::PacketStream
|
153
|
+
end # class::Ssh
|
154
|
+
end # module::EventMachine
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module EventMachine
|
2
|
+
class Ssh
|
3
|
+
class ServerVersion
|
4
|
+
include Log
|
5
|
+
|
6
|
+
attr_reader :header
|
7
|
+
attr_reader :version
|
8
|
+
|
9
|
+
def initialize(connection)
|
10
|
+
debug("#{self}.new(#{connection})")
|
11
|
+
negotiate!(connection)
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def negotiate!(connection)
|
18
|
+
@version = ''
|
19
|
+
cb = connection.on(:data) do |data|
|
20
|
+
log.debug("#{self.class}.on(:data, #{data.inspect})")
|
21
|
+
@version << data
|
22
|
+
@header = @version.clone
|
23
|
+
if @version[-1] == "\n"
|
24
|
+
@version.chomp!
|
25
|
+
log.debug("server version: #{@version}")
|
26
|
+
raise SshError.new("incompatible SSH version `#{@version}'") unless @version.match(/^SSH-(1\.99|2\.0)-/)
|
27
|
+
log.debug("local version: #{Net::SSH::Transport::ServerVersion::PROTO_VERSION}")
|
28
|
+
connection.send_data("#{Net::SSH::Transport::ServerVersion::PROTO_VERSION}\r\n")
|
29
|
+
cb.cancel
|
30
|
+
connection.fire(:version_negotiated)
|
31
|
+
end # @header[-1] == "\n"
|
32
|
+
end # |data|
|
33
|
+
end
|
34
|
+
|
35
|
+
end # class::ServerVersion
|
36
|
+
end # module::Ssh
|
37
|
+
end # module::EventMachine
|
@@ -0,0 +1,51 @@
|
|
1
|
+
|
2
|
+
module EventMachine
|
3
|
+
class Ssh
|
4
|
+
class Session < Net::SSH::Connection::Session
|
5
|
+
include Log
|
6
|
+
|
7
|
+
def initialize(transport, options = {})
|
8
|
+
super(transport, options)
|
9
|
+
register_callbacks
|
10
|
+
end
|
11
|
+
|
12
|
+
# Override the default, blocking behavior of Net::SSH.
|
13
|
+
# Callers to loop will still wait, but not block the loop.
|
14
|
+
def loop(wait=nil, &block)
|
15
|
+
f = Fiber.current
|
16
|
+
l = proc do
|
17
|
+
block.call ? EM.next_tick(&l) : f.resume
|
18
|
+
end
|
19
|
+
EM.next_tick(&l)
|
20
|
+
return Fiber.yield
|
21
|
+
end
|
22
|
+
|
23
|
+
# Override the default, blocking behavior of Net::SSH
|
24
|
+
def process(wait=nil, &block)
|
25
|
+
return true
|
26
|
+
end
|
27
|
+
|
28
|
+
def send_message(msg)
|
29
|
+
transport.send_message(msg)
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
|
36
|
+
def register_callbacks
|
37
|
+
transport.on(:packet) do |packet|
|
38
|
+
raise SshError, "unexpected response #{packet.type} (#{packet.inspect})" unless MAP.key?(packet.type)
|
39
|
+
send(MAP[packet.type], packet)
|
40
|
+
end # |packet|
|
41
|
+
|
42
|
+
chann_proc = proc do
|
43
|
+
channels.each { |id, channel| channel.process unless channel.closing? }
|
44
|
+
EM.next_tick(&chann_proc)
|
45
|
+
end
|
46
|
+
EM.next_tick(&chann_proc)
|
47
|
+
end # register_callbacks
|
48
|
+
|
49
|
+
end # class::Session
|
50
|
+
end # class::Ssh
|
51
|
+
end # module::EventMachine
|
data/lib/em-ssh/shell.rb
ADDED
@@ -0,0 +1,225 @@
|
|
1
|
+
require 'em-ssh'
|
2
|
+
|
3
|
+
module EventMachine
|
4
|
+
class Ssh
|
5
|
+
# EM::Ssh::Shell encapsulates interaction with a user shell on an SSH server.
|
6
|
+
# @example Retrieve the output of ifconfig -a on a server
|
7
|
+
# EM.run{
|
8
|
+
# shell = EM::Ssh::Shell.new(host, user, password)
|
9
|
+
# shell.wait_for('@icaleb ~]$')
|
10
|
+
# interfaces = send_and_wait('/sbin/ifconfig -a', '@icaleb ~]$')
|
11
|
+
#
|
12
|
+
# Shells can be easily and quickly duplicated (#split) without the need to establish another connection.
|
13
|
+
# Shells provide :closed, :childless, and :split callbacks.
|
14
|
+
#
|
15
|
+
# @example Start another shell using the same connection
|
16
|
+
# shell.on(:childless) do
|
17
|
+
# info("#{shell}'s children all closed")
|
18
|
+
# shell.disconnect
|
19
|
+
# EM.stop
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# admin_shell = shell.split
|
23
|
+
# admin_shell.on(:closed) { warn("admin shell has closed") }
|
24
|
+
# admin_shell.send_and_wait('sudo su -', ']$')
|
25
|
+
class Shell
|
26
|
+
include Log
|
27
|
+
include Callbacks
|
28
|
+
|
29
|
+
# Global timeout for wait operations; can be overriden by :timeout option to new
|
30
|
+
TIMEOUT = 15
|
31
|
+
# @return [Net::SSH::Connection::Channel] The shell to which we can send_data
|
32
|
+
attr_reader :shell
|
33
|
+
# @return [Net::SSH::Connection]
|
34
|
+
attr_reader :connection
|
35
|
+
# @return [Boolean] Default (false) halt on timeout value - when true any timeouts will be halt the shell by raising an exception
|
36
|
+
attr_reader :halt_on_timeout
|
37
|
+
# @return [Hash] the options passed to initialize
|
38
|
+
attr_reader :options
|
39
|
+
# @return [Hash] the options to pass to connect automatically. They will be extracted from the opptions[:net_ssh] on initialization
|
40
|
+
attr_reader :connect_opts
|
41
|
+
|
42
|
+
# @return [String] the host to login to
|
43
|
+
attr_reader :host
|
44
|
+
# @return [String] The user to authenticate as
|
45
|
+
attr_reader :user
|
46
|
+
# @return [String] the password to authenticate with - can be nil
|
47
|
+
attr_reader :pass
|
48
|
+
# @return [Array] all shells that have been split off from this one.
|
49
|
+
attr_reader :children
|
50
|
+
# @return [Shell] the parent of this shell
|
51
|
+
attr_reader :parent
|
52
|
+
# @return [String] a string (\r\n) to append to every command
|
53
|
+
def line_terminator
|
54
|
+
@line_terminator ||= "\r\n"
|
55
|
+
end
|
56
|
+
# [String]
|
57
|
+
attr_writer :line_terminator
|
58
|
+
|
59
|
+
# Connect to an ssh server then start a user shell.
|
60
|
+
# @param [String] address
|
61
|
+
# @param [String] user
|
62
|
+
# @param [String, nil] pass by default publickey and password auth will be attempted
|
63
|
+
# @param [Hash] opts
|
64
|
+
# @option opts [Hash] :net_ssh options to pass to Net::SSH; see Net::SSH.start
|
65
|
+
# @option opts [Boolean] :halt_on_timeout (false)
|
66
|
+
# @option opts [Fixnum] :timeout (TIMEOUT) default timeout for all #wait_for and #send_wait calls.
|
67
|
+
def initialize(address, user, pass, opts = {}, &blk)
|
68
|
+
@halt_on_timeout = opts[:halt_on_timeout] || false
|
69
|
+
@timeout = opts[:timeout].is_a?(Fixnum) ? opts[:timeout] : TIMEOUT
|
70
|
+
@host = address
|
71
|
+
@user = user
|
72
|
+
@pass = pass
|
73
|
+
@options = opts
|
74
|
+
@connect_opts = {:password => pass, :port => 22, :auth_methods => ['publickey', 'password']}.merge(opts[:net_ssh] || {})
|
75
|
+
@connection = opts[:connection]
|
76
|
+
@parent = opts[:parent]
|
77
|
+
@children = []
|
78
|
+
|
79
|
+
block_given? ? open(&blk) : open
|
80
|
+
end
|
81
|
+
|
82
|
+
# Close the connection to the server and all child shells.
|
83
|
+
# Disconnected shells cannot be split.
|
84
|
+
def disconnect
|
85
|
+
close
|
86
|
+
connection.close
|
87
|
+
end
|
88
|
+
|
89
|
+
# @return [Boolean] true if the connection is still alive
|
90
|
+
def connected?
|
91
|
+
connection && !connection.closed?
|
92
|
+
end
|
93
|
+
|
94
|
+
# Close this shell and all children.
|
95
|
+
# Even when a shell is closed it is still connected to the server.
|
96
|
+
# Fires :closed event.
|
97
|
+
# @see Callbacks
|
98
|
+
def close
|
99
|
+
shell.close.tap{ debug("closing") } if shell.active?
|
100
|
+
@closed = true
|
101
|
+
children.each { |c| c.close }
|
102
|
+
fire(:closed)
|
103
|
+
end
|
104
|
+
|
105
|
+
# @return [Boolean] Has this shell been closed.
|
106
|
+
def closed?
|
107
|
+
@closed == true
|
108
|
+
end
|
109
|
+
|
110
|
+
# Send a string to the server and wait for a response containing a specified String or Regex.
|
111
|
+
# @param [String] send_str
|
112
|
+
# @return [String] all data in the buffer including the wait_str if it was found
|
113
|
+
def send_and_wait(send_str, wait_str = nil, opts = {})
|
114
|
+
raise ClosedChannel if closed?
|
115
|
+
debug("send_and_wait(#{send_str.inspect}, #{wait_str.inspect}, #{opts})")
|
116
|
+
send_data(send_str)
|
117
|
+
return wait_for(wait_str, opts)
|
118
|
+
end # send_and_wait(send_str, wait_str = nil, opts = {})
|
119
|
+
|
120
|
+
# Wait for the shell to send data containing the given string.
|
121
|
+
# @param [String, Regexp] strregex a string or regex to match the console output against.
|
122
|
+
# @param [Hash] opts
|
123
|
+
# @option opts [Fixnum] :timeout (Session::TIMEOUT) the maximum number of seconds to wait
|
124
|
+
# @option opts [Boolean] (false) :halt_on_timeout
|
125
|
+
# @return [String] the contents of the buffer
|
126
|
+
def wait_for(strregex, opts = { })
|
127
|
+
raise ClosedChannel if closed?
|
128
|
+
debug("wait_for(#{strregex}, #{opts})")
|
129
|
+
opts = { :timeout => @timeout, :halt_on_timeout => @halt_on_timeout }.merge(opts)
|
130
|
+
buffer = ''
|
131
|
+
found = nil
|
132
|
+
f = Fiber.current
|
133
|
+
|
134
|
+
timer = nil
|
135
|
+
timeout = proc do
|
136
|
+
shell.on_data {|c,d| }
|
137
|
+
# @todo fire an em errback
|
138
|
+
if opts[:halt_on_timeout]
|
139
|
+
raise TimeoutError("timeout while waiting for #{strregex.inspect}; received: #{buffer.inspect}")
|
140
|
+
else
|
141
|
+
warn("timeout while waiting for #{strregex.inspect}; received: #{buffer.inspect}")
|
142
|
+
end # opts[:halt_on_timeout]
|
143
|
+
end # timeout
|
144
|
+
|
145
|
+
shell.on_data do |ch,data|
|
146
|
+
buffer = "#{buffer}#{data}"
|
147
|
+
if strregex.is_a?(Regexp) ? buffer.match(strregex) : buffer.include?(strregex)
|
148
|
+
timer.respond_to?(:cancel) && timer.cancel
|
149
|
+
result = buffer.clone
|
150
|
+
shell.on_data {|c,d| }
|
151
|
+
f.resume(result)
|
152
|
+
end
|
153
|
+
end # |ch,data|
|
154
|
+
|
155
|
+
timer = EM::Timer.new(opts[:timeout], &timeout)
|
156
|
+
return Fiber.yield
|
157
|
+
end # wait_for(strregex, opts = { })
|
158
|
+
|
159
|
+
# Open a shell on the server.
|
160
|
+
# You generally don't need to call this.
|
161
|
+
# @return [self]
|
162
|
+
def open
|
163
|
+
f = Fiber.current
|
164
|
+
connect unless connected?
|
165
|
+
|
166
|
+
connection.open_channel do |channel|
|
167
|
+
debug "**** channel open: #{channel}"
|
168
|
+
channel.request_pty(options[:pty] || {}) do |pty,suc|
|
169
|
+
debug "***** pty open: #{pty}; suc: #{suc}"
|
170
|
+
pty.send_channel_request("shell") do |shell,success|
|
171
|
+
raise ConnectionError, "Failed to create shell." unless success
|
172
|
+
debug "***** shell open: #{shell}"
|
173
|
+
@shell = shell
|
174
|
+
f.resume(self)
|
175
|
+
end # |shell,success|
|
176
|
+
end # |pty,suc|
|
177
|
+
end # |channel|
|
178
|
+
|
179
|
+
return Fiber.yield
|
180
|
+
end # start
|
181
|
+
|
182
|
+
# Create a new shell using the same ssh connection.
|
183
|
+
# A connection will be established if this shell is not connected.
|
184
|
+
# If a block is provided the child will be closed after yielding.
|
185
|
+
# @yield [Shell] child
|
186
|
+
# @return [Shell] child
|
187
|
+
def split
|
188
|
+
connect unless connected?
|
189
|
+
child = self.class.new(host, user, pass, {:connection => connection, :parent => self}.merge(options))
|
190
|
+
child.line_terminator = line_terminator
|
191
|
+
children.push(child)
|
192
|
+
child.on(:closed) do
|
193
|
+
children.delete(child)
|
194
|
+
fire(:childless).tap{ info("fired :childless") } if children.empty?
|
195
|
+
end
|
196
|
+
fire(:split, child)
|
197
|
+
block_given? ? yield(child).tap { child.close } : child
|
198
|
+
end # split
|
199
|
+
|
200
|
+
# Connect to the server.
|
201
|
+
# Does not open the shell; use #open or #split
|
202
|
+
# You generally won't need to call this on your own.
|
203
|
+
def connect
|
204
|
+
f = Fiber.current
|
205
|
+
::EM::Ssh.start(host, user, connect_opts) do |connection|
|
206
|
+
@connection = connection
|
207
|
+
f.resume
|
208
|
+
end # |connection|
|
209
|
+
return Fiber.yield
|
210
|
+
end # connect
|
211
|
+
|
212
|
+
|
213
|
+
# Send data to the ssh server shell.
|
214
|
+
# You generally don't need to call this.
|
215
|
+
# @see #send_and_wait
|
216
|
+
# @param [String] d the data to send encoded as a string
|
217
|
+
def send_data(d)
|
218
|
+
#debug("send_data: #{d.inspect}#{line_terminator}")
|
219
|
+
shell.send_data("#{d}#{line_terminator}")
|
220
|
+
end
|
221
|
+
|
222
|
+
|
223
|
+
end # class::Shell
|
224
|
+
end # class::Ssh
|
225
|
+
end # module::EventMachine
|
data/lib/em-ssh.rb
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
|
3
|
+
require 'fiber'
|
4
|
+
require 'timeout'
|
5
|
+
|
6
|
+
require 'net/ssh'
|
7
|
+
require 'em-ssh/log'
|
8
|
+
|
9
|
+
module EventMachine
|
10
|
+
# @example
|
11
|
+
# EM::Ssh.start(host, user, :password => password) do |ssh|
|
12
|
+
# ssh.exec("hostname") do |ch,stream,data|
|
13
|
+
# puts "data: #{data}"
|
14
|
+
# end
|
15
|
+
# end
|
16
|
+
class Ssh
|
17
|
+
DEFAULT_PORT = 22
|
18
|
+
|
19
|
+
# Generic error tag
|
20
|
+
module Error; end
|
21
|
+
# Any class that inherits from SshError will be an Exception and include a Ssh::Error tag
|
22
|
+
class SshError < Net::SSH::Exception; include Error; end
|
23
|
+
class TimeoutError < Timeout::Error; include Error; end
|
24
|
+
class ClosedChannel < SshError; end
|
25
|
+
|
26
|
+
class << self
|
27
|
+
attr_writer :logger
|
28
|
+
# Creates a logger when necessary
|
29
|
+
# @return [Logger]
|
30
|
+
def logger(level = Logger::WARN)
|
31
|
+
@logger ||= ::Logger.new(STDERR).tap{ |l| l.level = level }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Connect to an ssh server
|
35
|
+
# @param [String] host
|
36
|
+
# @param [String] user
|
37
|
+
# @param [Hash] opts all options accepted by Net::SSH.start
|
38
|
+
# @yield [Session] an EventMachine compatible Net::SSH::Session
|
39
|
+
# @see http://net-ssh.github.com/ssh/v2/api/index.html
|
40
|
+
# @return [Session]
|
41
|
+
# @example
|
42
|
+
# EM::Ssh.start(host, user, options) do |connection|
|
43
|
+
# log.debug "**** connected: #{connection}"
|
44
|
+
# connection.open_channel do |channel|
|
45
|
+
# log.debug "**** channel: #{channel}"
|
46
|
+
# channel.request_pty(options[:pty] || {}) do |pty,suc|
|
47
|
+
def connect(host, user, opts = {}, &blk)
|
48
|
+
logger.debug("#{self}.connect(#{host}, #{user}, #{opts})")
|
49
|
+
options = { :host => host, :user => user, :port => DEFAULT_PORT, :callback => blk }.merge(opts)
|
50
|
+
EM.connect(options[:host], options[:port], Connection, options)
|
51
|
+
end
|
52
|
+
alias :start :connect
|
53
|
+
end # << self
|
54
|
+
|
55
|
+
# Pull in the constants from Net::SSH::[Transport, Connection and Authentication]
|
56
|
+
# and define them locally.
|
57
|
+
[:Transport, :Connection, :Authentication]
|
58
|
+
.map{ |sym| Net::SSH.const_get(sym).const_get(:Constants) }
|
59
|
+
.each do |mod|
|
60
|
+
mod.constants.each do |name|
|
61
|
+
const_set(name, mod.const_get(name))
|
62
|
+
end # |name|
|
63
|
+
end # |module|
|
64
|
+
|
65
|
+
end # class::Ssh
|
66
|
+
end # module::EventMachine
|
67
|
+
|
68
|
+
|
69
|
+
require 'em-ssh/callbacks'
|
70
|
+
require 'em-ssh/connection'
|
71
|
+
require 'em-ssh/server-version'
|
72
|
+
require 'em-ssh/packet-stream'
|
73
|
+
require 'em-ssh/authentication-session'
|
74
|
+
require 'em-ssh/session'
|
metadata
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: em-ssh
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Caleb Crane
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2011-10-22 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: eventmachine
|
16
|
+
requirement: &70157793111680 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70157793111680
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: net-ssh
|
27
|
+
requirement: &70157793111020 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70157793111020
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: ruby-termios
|
38
|
+
requirement: &70157793110100 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ! '>='
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *70157793110100
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: highline
|
49
|
+
requirement: &70157793109680 !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
type: :development
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: *70157793109680
|
58
|
+
description: ''
|
59
|
+
email:
|
60
|
+
- em-ssh@simulacre.org
|
61
|
+
executables:
|
62
|
+
- em-ssh
|
63
|
+
- em-ssh-shell
|
64
|
+
extensions: []
|
65
|
+
extra_rdoc_files: []
|
66
|
+
files:
|
67
|
+
- lib/em-ssh/authentication-session.rb
|
68
|
+
- lib/em-ssh/callbacks.rb
|
69
|
+
- lib/em-ssh/connection.rb
|
70
|
+
- lib/em-ssh/log.rb
|
71
|
+
- lib/em-ssh/packet-stream.rb
|
72
|
+
- lib/em-ssh/server-version.rb
|
73
|
+
- lib/em-ssh/session.rb
|
74
|
+
- lib/em-ssh/shell.rb
|
75
|
+
- lib/em-ssh/version.rb
|
76
|
+
- lib/em-ssh.rb
|
77
|
+
- bin/em-ssh
|
78
|
+
- bin/em-ssh-shell
|
79
|
+
- README.md
|
80
|
+
homepage: http://github.com/simulacre/em-ssh
|
81
|
+
licenses:
|
82
|
+
- MIT
|
83
|
+
post_install_message:
|
84
|
+
rdoc_options: []
|
85
|
+
require_paths:
|
86
|
+
- lib
|
87
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
88
|
+
none: false
|
89
|
+
requirements:
|
90
|
+
- - ! '>='
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '0'
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
none: false
|
95
|
+
requirements:
|
96
|
+
- - ! '>='
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: 1.3.6
|
99
|
+
requirements: []
|
100
|
+
rubyforge_project:
|
101
|
+
rubygems_version: 1.8.10
|
102
|
+
signing_key:
|
103
|
+
specification_version: 3
|
104
|
+
summary: An EventMachine compatible net-ssh
|
105
|
+
test_files: []
|
106
|
+
has_rdoc:
|