├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── README.rdoc ├── Rakefile ├── lib └── net │ └── ssh │ ├── shell.rb │ └── shell │ ├── process.rb │ ├── subshell.rb │ └── version.rb └── net-ssh-shell.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | pkg 3 | doc 4 | Gemfile.lock 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.0 (unreleased) 2 | 3 | 4 | 5 | ## 0.2.0 (June 13, 2011) 6 | 7 | - Loosen regex on `on_stdout` [GH-1] 8 | - Capture stderr from shell. This won't capture the stderr of the processes 9 | running. Instead it will just capture any error output from the shell process. 10 | - Ability to specify the default process class to use for `Shell#execute` 11 | 12 | ## 0.1.0 (October 24, 2010) 13 | 14 | - Initial release 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "net-ssh-shell", :path => '.' 4 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Net::SSH::Shell 2 | 3 | == DESCRIPTION: 4 | 5 | Net::SSH::Shell is a library for interacting with stateful (e.g., interactive) shells on remote hosts. It hides (or tries to hide) the potentially complex Net::SSH state machines you'd otherwise need to write to interact with "su" and similar shells. 6 | 7 | One of the significant benefits you get from this library versus using Net::SSH directly is that your shell is _stateful_. With Net::SSH, if you invoke "cd /" in one exec call, and "pwd" in another, the two are done in different shells so the directory change in the first has no effect on the working directory of the second. With Net::SSH::Shell, though, commands are all invoked via the _same_ shell, so changes in directory or additions to the environment all affect subsequent commands. 8 | 9 | == FEATURES: 10 | 11 | * Interact with login shells 12 | * Support for "subshell" environments, like "su" or "sudo bash" 13 | 14 | == SYNOPSIS: 15 | 16 | In a nutshell: 17 | 18 | require 'net/ssh' 19 | require 'net/ssh/shell' 20 | 21 | Net::SSH::start('host', 'user') do |ssh| 22 | ssh.shell do |sh| 23 | sh.execute "cd /usr/local" 24 | sh.execute "pwd" 25 | sh.execute "export FOO=bar" 26 | sh.execute "echo $FOO" 27 | p=sh.execute "grep dont /tmp/notexist" 28 | puts "Exit Status:#{p.exit_status}" 29 | puts "Command Executed:#{p.command}" 30 | end 31 | end 32 | 33 | See Net::SSH::Shell for more documentation. 34 | 35 | == REQUIREMENTS: 36 | 37 | * net-ssh (version 2) 38 | 39 | If you want to use any of the Rake tasks, you'll need: 40 | 41 | * Echoe (for the Rakefile) 42 | 43 | == INSTALL: 44 | 45 | This gem is available from RubyGems, so you can install it using the "gem" command: 46 | 47 | * gem install net-ssh-shell 48 | 49 | If you'd like to build the gem for yourself from source: 50 | * git clone http://github.com/jedi4ever/net-ssh-shell.git 51 | * cd net-ssh-shell 52 | * gem install echoe 53 | * rake gem 54 | * gem install pkg/net-ssh-shell-0.1.0.gem (might need sudo privileges) 55 | 56 | == LICENSE: 57 | 58 | (The MIT License) 59 | 60 | Copyright (c) 2009 Jamis Buck 61 | 62 | Permission is hereby granted, free of charge, to any person obtaining 63 | a copy of this software and associated documentation files (the 64 | 'Software'), to deal in the Software without restriction, including 65 | without limitation the rights to use, copy, modify, merge, publish, 66 | distribute, sublicense, and/or sell copies of the Software, and to 67 | permit persons to whom the Software is furnished to do so, subject to 68 | the following conditions: 69 | 70 | The above copyright notice and this permission notice shall be 71 | included in all copies or substantial portions of the Software. 72 | 73 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 74 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 75 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 76 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 77 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 78 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 79 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 80 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | Bundler::GemHelper.install_tasks 4 | -------------------------------------------------------------------------------- /lib/net/ssh/shell.rb: -------------------------------------------------------------------------------- 1 | require 'digest/sha1' 2 | require 'net/ssh' 3 | require 'net/ssh/shell/process' 4 | require 'net/ssh/shell/subshell' 5 | 6 | module Net 7 | module SSH 8 | class Shell 9 | attr_reader :session 10 | attr_reader :channel 11 | attr_reader :state 12 | attr_reader :shell 13 | attr_reader :processes 14 | attr_accessor :default_process_class 15 | 16 | def initialize(session, shell=:default) 17 | @session = session 18 | @shell = shell 19 | @state = :closed 20 | @processes = [] 21 | @when_open = [] 22 | @on_process_run = nil 23 | @default_process_class = Net::SSH::Shell::Process 24 | open 25 | end 26 | 27 | def open(&callback) 28 | if closed? 29 | @state = :opening 30 | @channel = session.open_channel(&method(:open_succeeded)) 31 | @channel.on_open_failed(&method(:open_failed)) 32 | @channel.on_request('exit-status', &method(:on_exit_status)) 33 | end 34 | when_open(&callback) if callback 35 | self 36 | end 37 | 38 | def open! 39 | if !open? 40 | open if closed? 41 | session.loop { opening? } 42 | end 43 | self 44 | end 45 | 46 | def when_open(&callback) 47 | if open? 48 | yield self 49 | else 50 | @when_open << callback 51 | end 52 | self 53 | end 54 | 55 | def open? 56 | state == :open 57 | end 58 | 59 | def closed? 60 | state == :closed 61 | end 62 | 63 | def opening? 64 | !open? && !closed? 65 | end 66 | 67 | def on_process_run(&callback) 68 | @on_process_run = callback 69 | end 70 | 71 | def execute(command, *args, &callback) 72 | # The class is an optional second argument. 73 | klass = default_process_class 74 | klass = args.shift if args.first.is_a?(Class) 75 | 76 | # The properties are expected to be the next argument. 77 | props = {} 78 | props = args.shift if args.first.is_a?(Hash) 79 | 80 | process = klass.new(self, command, props, callback) 81 | processes << process 82 | run_next_process if processes.length == 1 83 | process 84 | end 85 | 86 | def subshell(command, &callback) 87 | execute(command, Net::SSH::Shell::Subshell, &callback) 88 | end 89 | 90 | def execute!(command, &callback) 91 | process = execute(command, &callback) 92 | wait! 93 | process 94 | end 95 | 96 | def busy? 97 | opening? || processes.any? 98 | end 99 | 100 | def wait! 101 | session.loop { busy? } 102 | end 103 | 104 | def close! 105 | channel.close if channel 106 | end 107 | 108 | def child_finished(child) 109 | channel.on_close(&method(:on_channel_close)) if !channel.nil? 110 | processes.delete(child) 111 | run_next_process 112 | end 113 | 114 | def separator 115 | @separator ||= begin 116 | s = Digest::SHA1.hexdigest([session.object_id, object_id, Time.now.to_i, Time.now.usec, rand(0xFFFFFFFF)].join(":")) 117 | s << Digest::SHA1.hexdigest(s) 118 | end 119 | end 120 | 121 | def on_channel_close(channel) 122 | @state = :closed 123 | @channel = nil 124 | end 125 | 126 | private 127 | 128 | def run_next_process 129 | if processes.any? 130 | process = processes.first 131 | @on_process_run.call(self, process) if @on_process_run 132 | process.run 133 | end 134 | end 135 | 136 | def open_succeeded(channel) 137 | @state = :pty 138 | channel.on_close(&method(:on_channel_close)) 139 | channel.request_pty(:modes => { Net::SSH::Connection::Term::ECHO => 0 }, &method(:pty_requested)) 140 | end 141 | 142 | def open_failed(channel, code, description) 143 | @state = :closed 144 | raise "could not open channel for process manager (#{description}, ##{code})" 145 | end 146 | 147 | def on_exit_status(channel, data) 148 | unless data.read_long == 0 149 | raise "the shell exited unexpectedly" 150 | end 151 | end 152 | 153 | def pty_requested(channel, success) 154 | @state = :shell 155 | raise "could not request pty for process manager" unless success 156 | if shell == :default 157 | channel.send_channel_request("shell", &method(:shell_requested)) 158 | else 159 | channel.exec(shell, &method(:shell_requested)) 160 | end 161 | end 162 | 163 | def shell_requested(channel, success) 164 | @state = :initializing 165 | raise "could not request shell for process manager" unless success 166 | channel.on_data(&method(:look_for_initialization_done)) 167 | channel.send_data "export PS1=; echo #{separator} $?\n" 168 | end 169 | 170 | def look_for_initialization_done(channel, data) 171 | if data.include?(separator) 172 | @state = :open 173 | @when_open.each { |callback| callback.call(self) } 174 | @when_open.clear 175 | end 176 | end 177 | end 178 | end 179 | end 180 | 181 | class Net::SSH::Connection::Session 182 | # Provides a convenient way to initialize a shell given a Net::SSH 183 | # session. Yields the new shell if a block is given. Returns the shell 184 | # instance. 185 | def shell(*args) 186 | shell = Net::SSH::Shell.new(self, *args) 187 | yield shell if block_given? 188 | shell 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/net/ssh/shell/process.rb: -------------------------------------------------------------------------------- 1 | module Net; module SSH; class Shell 2 | class Process 3 | attr_reader :state 4 | attr_reader :command 5 | attr_reader :manager 6 | attr_reader :callback 7 | attr_reader :exit_status 8 | attr_reader :properties 9 | 10 | def initialize(manager, command, properties, callback) 11 | @command = command 12 | @manager = manager 13 | @callback = callback 14 | @properties = properties 15 | @on_output = nil 16 | @on_error_output = nil 17 | @on_finish = nil 18 | @state = :new 19 | end 20 | 21 | def [](key) 22 | @properties[key] 23 | end 24 | 25 | def []=(key, value) 26 | @properties[key] = value 27 | end 28 | 29 | def send_data(data) 30 | manager.channel.send_data(data) 31 | end 32 | 33 | def run 34 | if state == :new 35 | state = :starting 36 | manager.open do 37 | state = :running 38 | manager.channel.on_data(&method(:on_stdout)) 39 | manager.channel.on_extended_data(&method(:on_stderr)) 40 | manager.channel.on_close(&method(:on_close)) 41 | 42 | callback.call(self) if callback 43 | 44 | cmd = command.dup 45 | cmd << ";" if cmd !~ /[;&]$/ 46 | cmd << " DONTEVERUSETHIS=$?; echo #{manager.separator} $DONTEVERUSETHIS; echo \"exit $DONTEVERUSETHIS\"|sh" 47 | 48 | send_data(cmd + "\n") 49 | end 50 | end 51 | 52 | self 53 | end 54 | 55 | def starting? 56 | state == :starting 57 | end 58 | 59 | def running? 60 | state == :running 61 | end 62 | 63 | def finished? 64 | state == :finished 65 | end 66 | 67 | def busy? 68 | starting? || running? 69 | end 70 | 71 | def wait! 72 | manager.session.loop { busy? } 73 | self 74 | end 75 | 76 | def on_output(&callback) 77 | @on_output = callback 78 | end 79 | 80 | def on_error_output(&callback) 81 | @on_error_output = callback 82 | end 83 | 84 | def on_finish(&callback) 85 | @on_finish = callback 86 | end 87 | 88 | protected 89 | 90 | def output!(data) 91 | @on_output.call(self, data) if @on_output 92 | end 93 | 94 | def on_stdout(ch, data) 95 | if data.strip =~ /#{manager.separator} (\d+)$/ 96 | before = $` 97 | output!(before) unless before.empty? 98 | finished!($1) 99 | else 100 | output!(data) 101 | end 102 | end 103 | 104 | def on_stderr(ch, type, data) 105 | @on_error_output.call(self, data) if @on_error_output 106 | end 107 | 108 | def on_close(ch) 109 | manager.on_channel_close(ch) 110 | finished!(-1) 111 | end 112 | 113 | def finished!(status) 114 | @state = :finished 115 | @exit_status = status.to_i 116 | @on_finish.call(self) if @on_finish 117 | manager.child_finished(self) 118 | end 119 | end 120 | end; end; end 121 | -------------------------------------------------------------------------------- /lib/net/ssh/shell/subshell.rb: -------------------------------------------------------------------------------- 1 | require 'net/ssh/shell/process' 2 | 3 | module Net; module SSH; class Shell 4 | class Subshell < Process 5 | protected 6 | 7 | def on_stdout(ch, data) 8 | if !output!(data) 9 | ch.on_data(&method(:look_for_finalize_initializer)) 10 | ch.send_data("export PS1=; echo #{manager.separator} $?\n") 11 | end 12 | end 13 | 14 | def look_for_finalize_initializer(ch, data) 15 | if data =~ /#{manager.separator} (\d+)/ 16 | ch.on_close(&@master_onclose) 17 | finished!($1) 18 | end 19 | end 20 | end 21 | end; end; end 22 | -------------------------------------------------------------------------------- /lib/net/ssh/shell/version.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | module SSH 3 | class Shell 4 | VERSION = "0.3.0.dev" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /net-ssh-shell.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/net/ssh/shell/version", __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "net-ssh-shell" 5 | s.version = Net::SSH::Shell::VERSION 6 | s.platform = Gem::Platform::RUBY 7 | s.authors = ["Jamis Buck"] 8 | s.email = ["jamis@jamisbuck.org"] 9 | s.homepage = "http://github.com/mitchellh/net-ssh-shell" 10 | s.summary = "A simple library to aid with stateful shell interactions" 11 | s.description = "A simple library to aid with stateful shell interactions" 12 | 13 | s.required_rubygems_version = ">= 1.3.6" 14 | s.rubyforge_project = "net-ssh-shell" 15 | 16 | s.add_dependency "net-ssh", "~> 2.1.0" 17 | 18 | s.add_development_dependency "rake" 19 | 20 | s.files = `git ls-files`.split("\n") 21 | s.executables = `git ls-files`.split("\n").map{|f| f =~ /^bin\/(.*)/ ? $1 : nil}.compact 22 | s.require_path = 'lib' 23 | end 24 | --------------------------------------------------------------------------------