capissh 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,157 @@
1
+ module Capissh
2
+ class Logger
3
+ attr_accessor :level, :device, :disable_formatters
4
+
5
+ IMPORTANT = 0
6
+ INFO = 1
7
+ DEBUG = 2
8
+ TRACE = 3
9
+
10
+ MAX_LEVEL = 3
11
+
12
+ COLORS = {
13
+ :none => "0",
14
+ :black => "30",
15
+ :red => "31",
16
+ :green => "32",
17
+ :yellow => "33",
18
+ :blue => "34",
19
+ :magenta => "35",
20
+ :cyan => "36",
21
+ :white => "37"
22
+ }
23
+
24
+ STYLES = {
25
+ :bright => 1,
26
+ :dim => 2,
27
+ :underscore => 4,
28
+ :blink => 5,
29
+ :reverse => 7,
30
+ :hidden => 8
31
+ }
32
+
33
+ # Set up default formatters
34
+ @formatters = [
35
+ # TRACE
36
+ { :match => /command finished/, :color => :white, :style => :dim, :level => 3, :priority => -10 },
37
+ { :match => /executing locally/, :color => :yellow, :level => 3, :priority => -20 },
38
+
39
+ # DEBUG
40
+ { :match => /executing `.*/, :color => :green, :level => 2, :priority => -10, :timestamp => true },
41
+ { :match => /.*/, :color => :yellow, :level => 2, :priority => -30 },
42
+
43
+ # INFO
44
+ { :match => /.*out\] (fatal:|ERROR:).*/, :color => :red, :level => 1, :priority => -10 },
45
+ { :match => /Permission denied/, :color => :red, :level => 1, :priority => -20 },
46
+ { :match => /sh: .+: command not found/, :color => :magenta, :level => 1, :priority => -30 },
47
+
48
+ # IMPORTANT
49
+ { :match => /^err ::/, :color => :red, :level => 0, :priority => -10 },
50
+ { :match => /.*/, :color => :blue, :level => 0, :priority => -20 }
51
+ ]
52
+
53
+ class << self
54
+ def add_formatter(options)
55
+ @formatters.push(options)
56
+ @sorted_formatters = nil
57
+ end
58
+
59
+ def sorted_formatters
60
+ # Sort matchers in reverse order so we can break if we found a match.
61
+ @sorted_formatters ||= @formatters.sort_by { |i| -(i[:priority] || i[:prio] || 0) }
62
+ end
63
+
64
+ def default
65
+ @default ||= new(:level => 3)
66
+ end
67
+ end
68
+
69
+ def initialize(options={})
70
+ output = options[:output] || $stderr
71
+ if output.respond_to?(:puts)
72
+ @device = output
73
+ else
74
+ @device = File.open(output.to_str, "a")
75
+ @needs_close = true
76
+ end
77
+
78
+ @options = options
79
+ @level = options[:level] || 0
80
+ @disable_formatters = options[:disable_formatters]
81
+ end
82
+
83
+ def close
84
+ device.close if @needs_close
85
+ end
86
+
87
+ def log(level, message, line_prefix=nil)
88
+ if level <= self.level
89
+ # Only format output if device is a TTY and formatters are not disabled
90
+ if device.tty? && !@disable_formatters
91
+ color = :none
92
+ style = nil
93
+
94
+ Logger.sorted_formatters.each do |formatter|
95
+ if (formatter[:level] == level || formatter[:level].nil?)
96
+ if message =~ formatter[:match] || line_prefix =~ formatter[:match]
97
+ color = formatter[:color] if formatter[:color]
98
+ style = formatter[:style] || formatter[:attribute] # (support original cap colors)
99
+ message.gsub!(formatter[:match], formatter[:replace]) if formatter[:replace]
100
+ message = formatter[:prepend] + message unless formatter[:prepend].nil?
101
+ message = message + formatter[:append] unless formatter[:append].nil?
102
+ message = Time.now.strftime('%Y-%m-%d %T') + ' ' + message if formatter[:timestamp]
103
+ break unless formatter[:replace]
104
+ end
105
+ end
106
+ end
107
+
108
+ if color == :hide
109
+ # Don't do anything if color is set to :hide
110
+ return false
111
+ end
112
+
113
+ term_color = COLORS[color]
114
+ term_style = STYLES[style]
115
+
116
+ # Don't format message if no color or style
117
+ unless color == :none and style.nil?
118
+ unless line_prefix.nil?
119
+ line_prefix = format(line_prefix, term_color, term_style, nil)
120
+ end
121
+ message = format(message, term_color, term_style)
122
+ end
123
+ end
124
+
125
+ indent = "%*s" % [MAX_LEVEL, "*" * (MAX_LEVEL - level)]
126
+ (RUBY_VERSION >= "1.9" ? message.lines : message).each do |line|
127
+ if line_prefix
128
+ device.puts "#{indent} [#{line_prefix}] #{line.strip}\n"
129
+ else
130
+ device.puts "#{indent} #{line.strip}\n"
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ def important(message, line_prefix=nil)
137
+ log(IMPORTANT, message, line_prefix)
138
+ end
139
+
140
+ def info(message, line_prefix=nil)
141
+ log(INFO, message, line_prefix)
142
+ end
143
+
144
+ def debug(message, line_prefix=nil)
145
+ log(DEBUG, message, line_prefix)
146
+ end
147
+
148
+ def trace(message, line_prefix=nil)
149
+ log(TRACE, message, line_prefix)
150
+ end
151
+
152
+ def format(message, color, style, nl = "\n")
153
+ style = "#{style};" if style
154
+ "\e[#{style}#{color}m" + message.to_s.strip + "\e[0m#{nl}"
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,54 @@
1
+ module Capissh
2
+ module Processable
3
+ module SessionAssociation
4
+ def self.on(exception, session)
5
+ unless exception.respond_to?(:session)
6
+ exception.extend(self)
7
+ exception.session = session
8
+ end
9
+
10
+ return exception
11
+ end
12
+
13
+ attr_accessor :session
14
+ end
15
+
16
+ # perform ssh session looping with reading/writing until the block returns false
17
+ def process_iteration(wait=nil, &block)
18
+ ensure_each_session { |session| session.preprocess }
19
+
20
+ return false if block && !block.call(self)
21
+
22
+ readers = sessions.map { |session| session.listeners.keys }.flatten.reject { |io| io.closed? }
23
+ writers = readers.select { |io| io.respond_to?(:pending_write?) && io.pending_write? }
24
+
25
+ if readers.any? || writers.any?
26
+ readers, writers, = IO.select(readers, writers, nil, wait)
27
+ end
28
+
29
+ if readers
30
+ ensure_each_session do |session|
31
+ ios = session.listeners.keys
32
+ session.postprocess(ios & readers, ios & writers)
33
+ end
34
+ end
35
+
36
+ true
37
+ end
38
+
39
+ def ensure_each_session
40
+ errors = []
41
+
42
+ sessions.each do |session|
43
+ begin
44
+ yield session
45
+ rescue Exception => error
46
+ errors << SessionAssociation.on(error, session)
47
+ end
48
+ end
49
+
50
+ raise errors.first if errors.any?
51
+ sessions
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,111 @@
1
+ module Capissh
2
+ class ServerDefinition
3
+ include Comparable
4
+
5
+ # Wraps a string in a ServerDefinition, if it isn't already.
6
+ def self.wrap_server(item, options)
7
+ item.is_a?(ServerDefinition) ? item : ServerDefinition.new(item, options)
8
+ end
9
+
10
+ # Turns a list, or something resembling a list, into a properly-formatted
11
+ # ServerDefinition list. Keep an eye on this one -- it's entirely too
12
+ # magical for its own good. In particular, if ServerDefinition ever inherits
13
+ # from Array, this will break.
14
+ def self.wrap_list(*list)
15
+ options = list.last.is_a?(Hash) ? list.pop : {}
16
+ if list.length == 1
17
+ if list.first.nil?
18
+ return []
19
+ elsif list.first.is_a?(Array)
20
+ list = list.first
21
+ end
22
+ end
23
+ options.merge! list.pop if list.last.is_a?(Hash)
24
+ list.map do |item|
25
+ self.wrap_server item, options
26
+ end
27
+ end
28
+
29
+ # The default user name to use when a user name is not explicitly provided
30
+ def self.default_user
31
+ ENV['USER'] || ENV['USERNAME'] || "not-specified"
32
+ end
33
+
34
+ attr_reader :user, :host, :port, :options, :server
35
+
36
+ # Initialize a ServerDefinition with a string or object that describes the
37
+ # authority URI part, "user@host:port", for connecting with SSH.
38
+ #
39
+ # Any object that responds to the following methods, in order of priority,
40
+ # can be used in a ServerDefinition:
41
+ #
42
+ # 1. #host, #user (optional), and #port (optional, default: 22)
43
+ # 2. #authority - responding with something like "[user@]host[:port]"
44
+ # 3. #to_s - responding with something like "[user@]host[:port]"
45
+ #
46
+ # If options are passed for the second argument, certain keys will be used:
47
+ #
48
+ # * :user - sets the user if one was not given in the authority
49
+ # * :port - sets the port if one was not given in the authority
50
+ # * :ssh_options - used for connecting with Net::SSH
51
+ def initialize(server, options={})
52
+ @server = server
53
+
54
+ if @server.respond_to?(:host)
55
+ @host = @server.host
56
+ @port = @server.port if @server.respond_to?(:port)
57
+ @user = @server.user if @server.respond_to?(:user)
58
+ else
59
+ if @server.respond_to?(:authority)
60
+ string = @server.authority
61
+ elsif @server.respond_to?(:to_s)
62
+ string = @server.to_s
63
+ else
64
+ raise ArgumentError, "Invalid server for ServerDefinition: #{@server.inspect}. Must respond to #host, #authority, or #to_s"
65
+ end
66
+ @user, @host, @port = string.match(/^(?:([^;,:=]+)@|)(.*?)(?::(\d+)|)$/)[1,3]
67
+ end
68
+
69
+ @options = options.dup
70
+ user_opt, port_opt = @options.delete(:user), @options.delete(:port)
71
+
72
+ @user ||= user_opt
73
+ @port ||= port_opt
74
+
75
+ @port = @port.to_i if @port
76
+ end
77
+
78
+ def <=>(server)
79
+ [host, port, user] <=> [server.host, server.port, server.user]
80
+ end
81
+
82
+ # Redefined, so that Array#uniq will work to remove duplicate server
83
+ # definitions, based solely on their host names.
84
+ def eql?(server)
85
+ host == server.host &&
86
+ user == server.user &&
87
+ port == server.port
88
+ end
89
+
90
+ alias :== :eql?
91
+
92
+ # Redefined, so that Array#uniq will work to remove duplicate server
93
+ # definitions, based on their connection information.
94
+ def hash
95
+ @hash ||= [host, user, port].hash
96
+ end
97
+
98
+ def connect_to_port
99
+ port || 22
100
+ end
101
+
102
+ def to_s
103
+ @to_s ||= begin
104
+ s = host
105
+ s = "#{user}@#{s}" if user
106
+ s = "#{s}:#{port}" if port && port != 22
107
+ s
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,110 @@
1
+ require 'net/ssh'
2
+
3
+ module Capissh
4
+ # A helper class for dealing with SSH connections.
5
+ class SSH
6
+ # Patch an accessor onto an SSH connection so that we can record the server
7
+ # definition object that defines the connection. This is useful because
8
+ # the gateway returns connections whose "host" is 127.0.0.1, instead of
9
+ # the host on the other side of the tunnel.
10
+ module Server
11
+ def self.apply_to(connection, server)
12
+ connection.extend(Server)
13
+ connection.xserver = server
14
+ connection
15
+ end
16
+
17
+ attr_accessor :xserver
18
+ end
19
+
20
+ # An abstraction to make it possible to connect to the server via public key
21
+ # without prompting for the password. If the public key authentication fails
22
+ # this will fall back to password authentication.
23
+ #
24
+ # +server+ must be an instance of ServerDefinition.
25
+ #
26
+ # If a block is given, the new session is yielded to it, otherwise the new
27
+ # session is returned.
28
+ #
29
+ # If an :ssh_options key exists in +options+, it is passed to the Net::SSH
30
+ # constructor. Values in +options+ are then merged into it, and any
31
+ # connection information in +server+ is added last, so that +server+ info
32
+ # takes precedence over +options+, which takes precendence over ssh_options.
33
+ def self.connect(server, options={})
34
+ connection_strategy(server, options) do |host, user, connection_options|
35
+ connection = Net::SSH.start(host, user, connection_options)
36
+ Server.apply_to(connection, server)
37
+ end
38
+ end
39
+
40
+ def self.gateway(gateway, options={})
41
+ connection_strategy(gateway, options) do |host, user, connection_options|
42
+ connection = Net::SSH::Gateway.new(host, user, connection_options)
43
+ Server.apply_to(connection, gateway)
44
+ end
45
+ end
46
+
47
+ # Abstracts the logic for establishing an SSH connection (which includes
48
+ # testing for connection failures and retrying with a password, and so forth,
49
+ # mostly made complicated because of the fact that some of these variables
50
+ # might be lazily evaluated and try to do something like prompt the user,
51
+ # which should only happen when absolutely necessary.
52
+ #
53
+ # This will yield the hostname, username, and a hash of connection options
54
+ # to the given block, which should return a new connection.
55
+ def self.connection_strategy(server, options={}, &block)
56
+ auth_methods = [ %w(publickey hostbased), %w(password keyboard-interactive) ]
57
+ password_value = nil
58
+
59
+ # construct the hash of ssh options that should be passed more-or-less
60
+ # directly to Net::SSH. This will be the general ssh options, merged with
61
+ # the server-specific ssh-options.
62
+ input_ssh_options = (options[:ssh_options] || {}).merge(server.options[:ssh_options] || {})
63
+
64
+ # load any SSH configuration files that were specified in the SSH options. This
65
+ # will load from ~/.ssh/config and /etc/ssh_config by default (see Net::SSH
66
+ # for details). Merge the explicitly given ssh_options over the top of the info
67
+ # from the config file.
68
+ ssh_options = Net::SSH.configuration_for(server.host, input_ssh_options.fetch(:config, true)).merge(input_ssh_options)
69
+
70
+ # Once we've loaded the config, we don't need Net::SSH to do it again.
71
+ ssh_options[:config] = false
72
+
73
+ ssh_options[:verbose] = :debug if options[:verbose] && options[:verbose] > 0
74
+
75
+ user =
76
+ server.user ||
77
+ options[:user] ||
78
+ ssh_options[:username] ||
79
+ ssh_options[:user] ||
80
+ ServerDefinition.default_user
81
+
82
+ port =
83
+ server.port ||
84
+ options[:port] ||
85
+ ssh_options[:port]
86
+
87
+ # the .ssh/config file might have changed the host-name on us
88
+ host = ssh_options.fetch(:host_name, server.host)
89
+
90
+ ssh_options[:port] = port if port
91
+
92
+ # delete these, since we've determined which username to use by this point
93
+ ssh_options.delete(:username)
94
+ ssh_options.delete(:user)
95
+
96
+ begin
97
+ connection_options = ssh_options.merge(
98
+ :password => password_value,
99
+ :auth_methods => ssh_options[:auth_methods] || auth_methods.shift
100
+ )
101
+
102
+ yield host, user, connection_options
103
+ rescue Net::SSH::AuthenticationFailed
104
+ raise if auth_methods.empty? || ssh_options[:auth_methods]
105
+ password_value = options[:password]
106
+ retry
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,218 @@
1
+ require 'net/scp'
2
+ require 'net/sftp'
3
+
4
+ require 'capissh/processable'
5
+
6
+ module Capissh
7
+ class Transfer
8
+ include Processable
9
+
10
+ def self.process(direction, from, to, sessions, options={}, &block)
11
+ new(direction, from, to, sessions, options, &block).process!
12
+ end
13
+
14
+ attr_reader :sessions
15
+ attr_reader :options
16
+ attr_reader :callback
17
+
18
+ attr_reader :transport
19
+ attr_reader :direction
20
+ attr_reader :from
21
+ attr_reader :to
22
+
23
+ attr_reader :logger
24
+ attr_reader :transfers
25
+
26
+ def initialize(direction, from, to, sessions, options={}, &block)
27
+ @direction = direction
28
+ @from = from
29
+ @to = to
30
+ @sessions = sessions
31
+ @options = options
32
+ @callback = block
33
+
34
+ @transport = options.fetch(:via, :sftp)
35
+ @logger = options.delete(:logger)
36
+
37
+ @session_map = {}
38
+
39
+ prepare_transfers
40
+ end
41
+
42
+ def process!
43
+ loop do
44
+ begin
45
+ break unless process_iteration { active? }
46
+ rescue Exception => error
47
+ if error.respond_to?(:session)
48
+ handle_error(error)
49
+ else
50
+ raise
51
+ end
52
+ end
53
+ end
54
+
55
+ failed = transfers.select { |txfr| txfr[:failed] }
56
+ if failed.any?
57
+ hosts = failed.map { |txfr| txfr[:server] }
58
+ errors = failed.map { |txfr| "#{txfr[:error]} (#{txfr[:error].message})" }.uniq.join(", ")
59
+ error = TransferError.new("#{operation} via #{transport} failed on #{hosts.join(',')}: #{errors}")
60
+ error.hosts = hosts
61
+
62
+ logger.important(error.message) if logger
63
+ raise error
64
+ end
65
+
66
+ logger.debug "#{transport} #{operation} complete" if logger
67
+ self
68
+ end
69
+
70
+ def active?
71
+ transfers.any? { |transfer| transfer.active? }
72
+ end
73
+
74
+ def operation
75
+ "#{direction}load"
76
+ end
77
+
78
+ def sanitized_from
79
+ if from.responds_to?(:read)
80
+ "#<#{from.class}>"
81
+ else
82
+ from
83
+ end
84
+ end
85
+
86
+ def sanitized_to
87
+ if to.responds_to?(:read)
88
+ "#<#{to.class}>"
89
+ else
90
+ to
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def session_map
97
+ @session_map
98
+ end
99
+
100
+ def prepare_transfers
101
+ logger.info "#{transport} #{operation} #{from} -> #{to}" if logger
102
+
103
+ @transfers = sessions.map do |session|
104
+ session_from = normalize(from, session)
105
+ session_to = normalize(to, session)
106
+
107
+ session_map[session] = case transport
108
+ when :sftp
109
+ prepare_sftp_transfer(session_from, session_to, session)
110
+ when :scp
111
+ prepare_scp_transfer(session_from, session_to, session)
112
+ else
113
+ raise ArgumentError, "unsupported transport type: #{transport.inspect}"
114
+ end
115
+ end
116
+ end
117
+
118
+ def prepare_scp_transfer(from, to, session)
119
+ real_callback = callback || Proc.new do |channel, name, sent, total|
120
+ logger.trace "[#{channel[:host]}] #{name}" if logger && sent == 0
121
+ end
122
+
123
+ channel = case direction
124
+ when :up
125
+ session.scp.upload(from, to, options, &real_callback)
126
+ when :down
127
+ session.scp.download(from, to, options, &real_callback)
128
+ else
129
+ raise ArgumentError, "unsupported transfer direction: #{direction.inspect}"
130
+ end
131
+
132
+ channel[:server] = session.xserver
133
+ channel[:host] = session.xserver.host
134
+
135
+ return channel
136
+ end
137
+
138
+ class SFTPTransferWrapper
139
+ attr_reader :operation
140
+
141
+ def initialize(session, &callback)
142
+ session.sftp(false).connect do |sftp|
143
+ @operation = callback.call(sftp)
144
+ end
145
+ end
146
+
147
+ def active?
148
+ @operation.nil? || @operation.active?
149
+ end
150
+
151
+ def [](key)
152
+ @operation[key]
153
+ end
154
+
155
+ def []=(key, value)
156
+ @operation[key] = value
157
+ end
158
+
159
+ def abort!
160
+ @operation.abort!
161
+ end
162
+ end
163
+
164
+ def prepare_sftp_transfer(from, to, session)
165
+ SFTPTransferWrapper.new(session) do |sftp|
166
+ real_callback = Proc.new do |event, op, *args|
167
+ if callback
168
+ callback.call(event, op, *args)
169
+ elsif event == :open
170
+ logger.trace "[#{op[:host]}] #{args[0].remote}"
171
+ elsif event == :finish
172
+ logger.trace "[#{op[:host]}] done"
173
+ end
174
+ end
175
+
176
+ opts = options.dup
177
+ opts[:properties] = (opts[:properties] || {}).merge(
178
+ :server => session.xserver,
179
+ :host => session.xserver.host)
180
+
181
+ case direction
182
+ when :up
183
+ sftp.upload(from, to, opts, &real_callback)
184
+ when :down
185
+ sftp.download(from, to, opts, &real_callback)
186
+ else
187
+ raise ArgumentError, "unsupported transfer direction: #{direction.inspect}"
188
+ end
189
+ end
190
+ end
191
+
192
+ def normalize(argument, session)
193
+ if argument.is_a?(String)
194
+ argument.gsub(/\$CAPISSH:HOST\$/, session.xserver.host)
195
+ elsif argument.respond_to?(:read)
196
+ pos = argument.pos
197
+ clone = StringIO.new(argument.read)
198
+ clone.pos = argument.pos = pos
199
+ clone
200
+ else
201
+ argument
202
+ end
203
+ end
204
+
205
+ def handle_error(error)
206
+ raise error if error.message.include?('expected a file to upload')
207
+
208
+ transfer = session_map[error.session]
209
+ transfer[:error] = error
210
+ transfer[:failed] = true
211
+
212
+ case transport
213
+ when :sftp then transfer.abort!
214
+ when :scp then transfer.close
215
+ end
216
+ end
217
+ end
218
+ end