train-core 2.0.5 → 2.0.8

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84c1a550a64252e1bc30666a40359220d3afdc76403ee8bd580c4fb49cd54c49
4
- data.tar.gz: 545681f44366939e9fa22e92b5bd8397fae14b0e5407ab92cdfc456679d5dd66
3
+ metadata.gz: 6296676d2fb3138e2e4e3f660e6bc9d871f10419318ba3344ca472c1eca7f0d2
4
+ data.tar.gz: 8c5d1f00ee0c1e4dffa9520c8c622cd5333716a90576d52dfc5856f2ce7bf6de
5
5
  SHA512:
6
- metadata.gz: 25c0429f61b1b911e4d32952e53815f689ea9e76eee8fc1d1a1bb8f04f1be82b6f442d9ee11cb1ac91cd162c98606ad9961290fc177625af32dd6ab72a90e258
7
- data.tar.gz: 0d1e3a0368830194f669f6623b253679d847a0018137c8a37610d1648d94d91e28497d8e34bd4f50a20515662f8dee890b70908121e81acf96b98b1788e2ccbd
6
+ metadata.gz: 97150adffc1f18041eb3c1f7c77c439920674a2f1d40ef90b7720eeb808bc98709d4cd646af0fae949a47229703dc19988ac2ca54b21e1de5aeb2b71a873a4d1
7
+ data.tar.gz: '028b9286800362e7e945c6fa5d3efde308e5b917233e7e9367b00ba289e87902ac4a8ccfcb55bf6879d7548340a40156e0cf2a05035c139d86d216dd27bda036'
@@ -0,0 +1,269 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
4
+ # Author:: Dominik Richter (<dominik.richter@gmail.com>)
5
+ # Author:: Christoph Hartmann (<chris@lollyrock.com>)
6
+ #
7
+ # Copyright (C) 2014, Fletcher Nichol
8
+ #
9
+ # Licensed under the Apache License, Version 2.0 (the "License");
10
+ # you may not use this file except in compliance with the License.
11
+ # You may obtain a copy of the License at
12
+ #
13
+ # http://www.apache.org/licenses/LICENSE-2.0
14
+ #
15
+ # Unless required by applicable law or agreed to in writing, software
16
+ # distributed under the License is distributed on an "AS IS" BASIS,
17
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
+ # See the License for the specific language governing permissions and
19
+ # limitations under the License.
20
+
21
+ require 'net/ssh'
22
+ require 'net/scp'
23
+ require 'train/errors'
24
+
25
+ module Train::Transports
26
+ # Wrapped exception for any internally raised SSH-related errors.
27
+ #
28
+ # @author Fletcher Nichol <fnichol@nichol.ca>
29
+ class SSHFailed < Train::TransportError; end
30
+ class SSHPTYFailed < Train::TransportError; end
31
+
32
+ # A Transport which uses the SSH protocol to execute commands and transfer
33
+ # files.
34
+ #
35
+ # @author Fletcher Nichol <fnichol@nichol.ca>
36
+ class SSH < Train.plugin(1) # rubocop:disable Metrics/ClassLength
37
+ name 'ssh'
38
+
39
+ require 'train/transports/ssh_connection'
40
+ require 'train/transports/cisco_ios_connection'
41
+
42
+ # add options for submodules
43
+ include_options Train::Extras::CommandWrapper
44
+
45
+ # common target configuration
46
+ option :host, required: true
47
+ option :port, default: 22, required: true
48
+ option :user, default: 'root', required: true
49
+ option :key_files, default: nil
50
+ option :password, default: nil
51
+
52
+ # additional ssh options
53
+ option :keepalive, default: true
54
+ option :keepalive_interval, default: 60
55
+ option :connection_timeout, default: 15
56
+ option :connection_retries, default: 5
57
+ option :connection_retry_sleep, default: 1
58
+ option :max_wait_until_ready, default: 600
59
+ option :compression, default: false
60
+ option :pty, default: false
61
+ option :proxy_command, default: nil
62
+ option :bastion_host, default: nil
63
+ option :bastion_user, default: 'root'
64
+ option :bastion_port, default: 22
65
+ option :non_interactive, default: false
66
+ option :verify_host_key, default: false
67
+
68
+ option :compression_level do |opts|
69
+ # on nil or false: set compression level to 0
70
+ opts[:compression] ? 6 : 0
71
+ end
72
+
73
+ # (see Base#connection)
74
+ def connection(state = {}, &block)
75
+ opts = merge_options(options, state || {})
76
+ validate_options(opts)
77
+ conn_opts = connection_options(opts)
78
+
79
+ if defined?(@connection) && @connection_options == conn_opts
80
+ reuse_connection(&block)
81
+ else
82
+ create_new_connection(conn_opts, &block)
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def validate_options(options)
89
+ super(options)
90
+
91
+ key_files = Array(options[:key_files])
92
+ options[:auth_methods] ||= ['none']
93
+
94
+ unless key_files.empty?
95
+ options[:auth_methods].push('publickey')
96
+ options[:keys_only] = true if options[:password].nil?
97
+ options[:key_files] = key_files
98
+ end
99
+
100
+ unless options[:password].nil?
101
+ options[:auth_methods].push('password', 'keyboard-interactive')
102
+ end
103
+
104
+ if options[:auth_methods] == ['none']
105
+ if ssh_known_identities.empty?
106
+ fail Train::ClientError,
107
+ 'Your SSH Agent has no keys added, and you have not specified a password or a key file'
108
+ else
109
+ logger.debug('[SSH] Using Agent keys as no password or key file have been specified')
110
+ options[:auth_methods].push('publickey')
111
+ end
112
+ end
113
+
114
+ if options[:pty]
115
+ logger.warn('[SSH] PTY requested: stderr will be merged into stdout')
116
+ end
117
+
118
+ if [options[:proxy_command], options[:bastion_host]].all? { |type| !type.nil? }
119
+ fail Train::ClientError, 'Only one of proxy_command or bastion_host needs to be specified'
120
+ end
121
+
122
+ super
123
+ self
124
+ end
125
+
126
+ # Creates an SSH Authentication KeyManager instance and saves it for
127
+ # potential future reuse.
128
+ #
129
+ # @return [Hash] hash of SSH Known Identities
130
+ # @api private
131
+ def ssh_known_identities
132
+ # Force KeyManager to load the key(s)
133
+ @manager ||= Net::SSH::Authentication::KeyManager.new(nil).each_identity {}
134
+ @manager.known_identities
135
+ end
136
+
137
+ # Builds the hash of options needed by the Connection object on
138
+ # construction.
139
+ #
140
+ # @param opts [Hash] merged configuration and mutable state data
141
+ # @return [Hash] hash of connection options
142
+ # @api private
143
+ def connection_options(opts)
144
+ connection_options = {
145
+ logger: logger,
146
+ user_known_hosts_file: '/dev/null',
147
+ hostname: opts[:host],
148
+ port: opts[:port],
149
+ username: opts[:user],
150
+ compression: opts[:compression],
151
+ compression_level: opts[:compression_level],
152
+ keepalive: opts[:keepalive],
153
+ keepalive_interval: opts[:keepalive_interval],
154
+ timeout: opts[:connection_timeout],
155
+ connection_retries: opts[:connection_retries],
156
+ connection_retry_sleep: opts[:connection_retry_sleep],
157
+ max_wait_until_ready: opts[:max_wait_until_ready],
158
+ auth_methods: opts[:auth_methods],
159
+ keys_only: opts[:keys_only],
160
+ keys: opts[:key_files],
161
+ password: opts[:password],
162
+ forward_agent: opts[:forward_agent],
163
+ proxy_command: opts[:proxy_command],
164
+ bastion_host: opts[:bastion_host],
165
+ bastion_user: opts[:bastion_user],
166
+ bastion_port: opts[:bastion_port],
167
+ non_interactive: opts[:non_interactive],
168
+ transport_options: opts,
169
+ }
170
+ # disable host key verification. The hash key and value to use
171
+ # depends on the version of net-ssh in use.
172
+ connection_options[verify_host_key_option] = verify_host_key_value(opts[:verify_host_key])
173
+
174
+ connection_options
175
+ end
176
+
177
+ #
178
+ # Returns the correct host-key-verification option key to use depending
179
+ # on what version of net-ssh is in use. In net-ssh <= 4.1, the supported
180
+ # parameter is `paranoid` but in 4.2, it became `verify_host_key`
181
+ #
182
+ # `verify_host_key` does not work in <= 4.1, and `paranoid` throws
183
+ # deprecation warnings in >= 4.2.
184
+ #
185
+ # While the "right thing" to do would be to pin train's dependency on
186
+ # net-ssh to ~> 4.2, this will prevent InSpec from being used in
187
+ # Chef v12 because of it pinning to a v3 of net-ssh.
188
+ #
189
+ def verify_host_key_option
190
+ current_net_ssh = Net::SSH::Version::CURRENT
191
+ new_option_version = Net::SSH::Version[4, 2, 0]
192
+
193
+ current_net_ssh >= new_option_version ? :verify_host_key : :paranoid
194
+ end
195
+
196
+ # Likewise, version <5 accepted false; 5+ requires :never or will
197
+ # issue a deprecation warning. This method allows a lot of common
198
+ # things through.
199
+ def verify_host_key_value(given)
200
+ current_net_ssh = Net::SSH::Version::CURRENT
201
+ new_value_version = Net::SSH::Version[5, 0, 0]
202
+ if current_net_ssh >= new_value_version
203
+ # 5.0+ style
204
+ {
205
+ # It's not a boolean anymore.
206
+ 'true' => :always,
207
+ 'false' => :never,
208
+ true => :always,
209
+ false => :never,
210
+ # May be correct value, but strings from JSON config
211
+ 'always' => :always,
212
+ 'never' => :never,
213
+ nil => :never,
214
+ }.fetch(given, given)
215
+ else
216
+ # up to 4.2 style
217
+ {
218
+ 'true' => true,
219
+ 'false' => false,
220
+ nil => false,
221
+ }.fetch(given, given)
222
+ end
223
+ end
224
+
225
+ # Creates a new SSH Connection instance and save it for potential future
226
+ # reuse.
227
+ #
228
+ # @param options [Hash] conneciton options
229
+ # @return [Ssh::Connection] an SSH Connection instance
230
+ # @api private
231
+ def create_new_connection(options, &block)
232
+ if defined?(@connection)
233
+ logger.debug("[SSH] shutting previous connection #{@connection}")
234
+ @connection.close
235
+ end
236
+
237
+ @connection_options = options
238
+ conn = Connection.new(options, &block)
239
+
240
+ # Cisco IOS requires a special implementation of `Net:SSH`. This uses the
241
+ # SSH transport to identify the platform, but then replaces SSHConnection
242
+ # with a CiscoIOSConnection in order to behave as expected for the user.
243
+ if defined?(conn.platform.cisco_ios?) && conn.platform.cisco_ios?
244
+ ios_options = {}
245
+ ios_options[:host] = @options[:host]
246
+ ios_options[:user] = @options[:user]
247
+ # The enable password is used to elevate privileges on Cisco devices
248
+ # We will also support the sudo password field for the same purpose
249
+ # for the interim. # TODO
250
+ ios_options[:enable_password] = @options[:enable_password] || @options[:sudo_password]
251
+ ios_options[:logger] = @options[:logger]
252
+ ios_options.merge!(@connection_options)
253
+ conn = CiscoIOSConnection.new(ios_options)
254
+ end
255
+
256
+ @connection = conn unless conn.nil?
257
+ end
258
+
259
+ # Return the last saved SSH connection instance.
260
+ #
261
+ # @return [Ssh::Connection] an SSH Connection instance
262
+ # @api private
263
+ def reuse_connection
264
+ logger.debug("[SSH] reusing existing connection #{@connection}")
265
+ yield @connection if block_given?
266
+ @connection
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,311 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
4
+ # Author:: Dominik Richter (<dominik.richter@gmail.com>)
5
+ # Author:: Christoph Hartmann (<chris@lollyrock.com>)
6
+ #
7
+ # Copyright (C) 2014, Fletcher Nichol
8
+ #
9
+ # Licensed under the Apache License, Version 2.0 (the "License");
10
+ # you may not use this file except in compliance with the License.
11
+ # You may obtain a copy of the License at
12
+ #
13
+ # http://www.apache.org/licenses/LICENSE-2.0
14
+ #
15
+ # Unless required by applicable law or agreed to in writing, software
16
+ # distributed under the License is distributed on an "AS IS" BASIS,
17
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
+ # See the License for the specific language governing permissions and
19
+ # limitations under the License.
20
+
21
+ require 'net/ssh'
22
+ require 'net/scp'
23
+ require 'timeout'
24
+
25
+ class Train::Transports::SSH
26
+ # A Connection instance can be generated and re-generated, given new
27
+ # connection details such as connection port, hostname, credentials, etc.
28
+ # This object is responsible for carrying out the actions on the remote
29
+ # host such as executing commands, transferring files, etc.
30
+ #
31
+ # @author Fletcher Nichol <fnichol@nichol.ca>
32
+ class Connection < BaseConnection # rubocop:disable Metrics/ClassLength
33
+ attr_reader :hostname
34
+ def initialize(options)
35
+ # Track IOS command retries to prevent infinite loop on IOError. This must
36
+ # be done before `super()` because the parent runs detection commands.
37
+ @ios_cmd_retries = 0
38
+ super(options)
39
+ @username = @options.delete(:username)
40
+ @hostname = @options.delete(:hostname)
41
+ @port = @options[:port] # don't delete from options
42
+ @connection_retries = @options.delete(:connection_retries)
43
+ @connection_retry_sleep = @options.delete(:connection_retry_sleep)
44
+ @max_wait_until_ready = @options.delete(:max_wait_until_ready)
45
+ @max_ssh_sessions = @options.delete(:max_ssh_connections) { 9 }
46
+ @session = nil
47
+ @transport_options = @options.delete(:transport_options)
48
+ @cmd_wrapper = nil
49
+ @proxy_command = @options.delete(:proxy_command)
50
+ @bastion_host = @options.delete(:bastion_host)
51
+ @bastion_user = @options.delete(:bastion_user)
52
+ @bastion_port = @options.delete(:bastion_port)
53
+ @cmd_wrapper = CommandWrapper.load(self, @transport_options)
54
+ end
55
+
56
+ # (see Base::Connection#close)
57
+ def close
58
+ return if @session.nil?
59
+ logger.debug("[SSH] closing connection to #{self}")
60
+ session.close
61
+ ensure
62
+ @session = nil
63
+ end
64
+
65
+ def ssh_opts
66
+ level = logger.debug? ? 'VERBOSE' : 'ERROR'
67
+ fwd_agent = options[:forward_agent] ? 'yes' : 'no'
68
+
69
+ args = %w{ -o UserKnownHostsFile=/dev/null }
70
+ args += %w{ -o StrictHostKeyChecking=no }
71
+ args += %w{ -o IdentitiesOnly=yes } if options[:keys]
72
+ args += %w{ -o BatchMode=yes } if options[:non_interactive]
73
+ args += %W( -o LogLevel=#{level} )
74
+ args += %W( -o ForwardAgent=#{fwd_agent} ) if options.key?(:forward_agent)
75
+ Array(options[:keys]).each do |ssh_key|
76
+ args += %W( -i #{ssh_key} )
77
+ end
78
+ args
79
+ end
80
+
81
+ def check_proxy
82
+ [@proxy_command, @bastion_host].any? { |type| !type.nil? }
83
+ end
84
+
85
+ def generate_proxy_command
86
+ return @proxy_command unless @proxy_command.nil?
87
+ args = %w{ ssh }
88
+ args += ssh_opts
89
+ args += %W( #{@bastion_user}@#{@bastion_host} )
90
+ args += %W( -p #{@bastion_port} )
91
+ args += %w{ -W %h:%p }
92
+ args.join(' ')
93
+ end
94
+
95
+ # (see Base::Connection#login_command)
96
+ def login_command
97
+ args = ssh_opts
98
+ args += %W( -o ProxyCommand='#{generate_proxy_command}' ) if check_proxy
99
+ args += %W( -p #{@port} )
100
+ args += %W( #{@username}@#{@hostname} )
101
+ LoginCommand.new('ssh', args)
102
+ end
103
+
104
+ # (see Base::Connection#upload)
105
+ def upload(locals, remote)
106
+ waits = []
107
+ Array(locals).each do |local|
108
+ opts = File.directory?(local) ? { recursive: true } : {}
109
+
110
+ waits.push session.scp.upload(local, remote, opts) do |_ch, name, sent, total|
111
+ logger.debug("Uploaded #{name} (#{total} bytes)") if sent == total
112
+ end
113
+ waits.shift.wait while waits.length >= @max_ssh_sessions
114
+ end
115
+ waits.each(&:wait)
116
+ rescue Net::SSH::Exception => ex
117
+ raise Train::Transports::SSHFailed, "SCP upload failed (#{ex.message})"
118
+ end
119
+
120
+ def download(remotes, local)
121
+ waits = []
122
+ Array(remotes).map do |remote|
123
+ opts = file(remote).directory? ? { recursive: true } : {}
124
+ waits.push session.scp.download(remote, local, opts) do |_ch, name, recv, total|
125
+ logger.debug("Downloaded #{name} (#{total} bytes)") if recv == total
126
+ end
127
+ waits.shift.wait while waits.length >= @max_ssh_sessions
128
+ end
129
+ waits.each(&:wait)
130
+ rescue Net::SSH::Exception => ex
131
+ raise Train::Transports::SSHFailed, "SCP download failed (#{ex.message})"
132
+ end
133
+
134
+ # (see Base::Connection#wait_until_ready)
135
+ def wait_until_ready
136
+ delay = 3
137
+ session(
138
+ retries: @max_wait_until_ready / delay,
139
+ delay: delay,
140
+ message: "Waiting for SSH service on #{@hostname}:#{@port}, " \
141
+ "retrying in #{delay} seconds",
142
+ )
143
+ run_command(PING_COMMAND.dup)
144
+ end
145
+
146
+ def uri
147
+ "ssh://#{@username}@#{@hostname}:#{@port}"
148
+ end
149
+
150
+ private
151
+
152
+ PING_COMMAND = "echo '[SSH] Established'".freeze
153
+
154
+ RESCUE_EXCEPTIONS_ON_ESTABLISH = [
155
+ Errno::EACCES, Errno::EADDRINUSE, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
156
+ Errno::ECONNRESET, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::EPIPE,
157
+ Net::SSH::Disconnect, Net::SSH::AuthenticationFailed, Net::SSH::ConnectionTimeout,
158
+ Timeout::Error
159
+ ].freeze
160
+
161
+ # Establish an SSH session on the remote host.
162
+ #
163
+ # @param opts [Hash] retry options
164
+ # @option opts [Integer] :retries the number of times to retry before
165
+ # failing
166
+ # @option opts [Float] :delay the number of seconds to wait until
167
+ # attempting a retry
168
+ # @option opts [String] :message an optional message to be logged on
169
+ # debug (overriding the default) when a rescuable exception is raised
170
+ # @return [Net::SSH::Connection::Session] the SSH connection session
171
+ # @api private
172
+ def establish_connection(opts)
173
+ logger.debug("[SSH] opening connection to #{self}")
174
+ if check_proxy
175
+ require 'net/ssh/proxy/command'
176
+ @options[:proxy] = Net::SSH::Proxy::Command.new(generate_proxy_command)
177
+ end
178
+ Net::SSH.start(@hostname, @username, @options.clone.delete_if { |_key, value| value.nil? })
179
+ rescue *RESCUE_EXCEPTIONS_ON_ESTABLISH => e
180
+ if (opts[:retries] -= 1) <= 0
181
+ logger.warn("[SSH] connection failed, terminating (#{e.inspect})")
182
+ raise Train::Transports::SSHFailed, 'SSH session could not be established'
183
+ end
184
+
185
+ if opts[:message]
186
+ logger.debug("[SSH] connection failed (#{e.inspect})")
187
+ message = opts[:message]
188
+ else
189
+ message = "[SSH] connection failed, retrying in #{opts[:delay]}"\
190
+ " seconds (#{e.inspect})"
191
+ end
192
+ logger.info(message)
193
+
194
+ sleep(opts[:delay])
195
+ retry
196
+ end
197
+
198
+ def file_via_connection(path)
199
+ if os.aix?
200
+ Train::File::Remote::Aix.new(self, path)
201
+ elsif os.solaris?
202
+ Train::File::Remote::Unix.new(self, path)
203
+ elsif os[:name] == 'qnx'
204
+ Train::File::Remote::Qnx.new(self, path)
205
+ else
206
+ Train::File::Remote::Linux.new(self, path)
207
+ end
208
+ end
209
+
210
+ def run_command_via_connection(cmd, &data_handler)
211
+ cmd.dup.force_encoding('binary') if cmd.respond_to?(:force_encoding)
212
+ logger.debug("[SSH] #{self} (#{cmd})")
213
+
214
+ reset_session if session.closed?
215
+ exit_status, stdout, stderr = execute_on_channel(cmd, &data_handler)
216
+
217
+ # Since `@session.loop` succeeded, reset the IOS command retry counter
218
+ @ios_cmd_retries = 0
219
+
220
+ CommandResult.new(stdout, stderr, exit_status)
221
+ rescue Net::SSH::Exception => ex
222
+ raise Train::Transports::SSHFailed, "SSH command failed (#{ex.message})"
223
+ rescue IOError
224
+ # Cisco IOS occasionally closes the stream prematurely while we are
225
+ # running commands to detect if we need to switch to the Cisco IOS
226
+ # transport. This retries the command if this is the case.
227
+ # See:
228
+ # https://github.com/inspec/train/pull/271
229
+ logger.debug('[SSH] Possible Cisco IOS race condition, retrying command')
230
+
231
+ # Only attempt retry up to 5 times to avoid infinite loop
232
+ @ios_cmd_retries += 1
233
+ raise if @ios_cmd_retries >= 5
234
+
235
+ retry
236
+ end
237
+
238
+ # Returns a connection session, or establishes one when invoked the
239
+ # first time.
240
+ #
241
+ # @param retry_options [Hash] retry options for the initial connection
242
+ # @return [Net::SSH::Connection::Session] the SSH connection session
243
+ # @api private
244
+ def session(retry_options = {})
245
+ @session ||= establish_connection({
246
+ retries: @connection_retries.to_i,
247
+ delay: @connection_retry_sleep.to_i,
248
+ }.merge(retry_options))
249
+ end
250
+
251
+ def reset_session
252
+ @session = nil
253
+ end
254
+
255
+ # String representation of object, reporting its connection details and
256
+ # configuration.
257
+ #
258
+ # @api private
259
+ def to_s
260
+ options_to_print = @options.clone
261
+ options_to_print[:password] = '<hidden>' if options_to_print.key?(:password)
262
+ "#{@username}@#{@hostname}<#{options_to_print.inspect}>"
263
+ end
264
+
265
+ # Given a channel and a command string, it will execute the command on the channel
266
+ # and accumulate results in @stdout/@stderr.
267
+ #
268
+ # @param channel [Net::SSH::Connection::Channel] an open ssh channel
269
+ # @param cmd [String] the command to execute
270
+ # @return [Integer] exit status or nil if exit-status/exit-signal requests
271
+ # not received.
272
+ #
273
+ # @api private
274
+ def execute_on_channel(cmd, &data_handler)
275
+ stdout = stderr = ''
276
+ exit_status = nil
277
+ session.open_channel do |channel|
278
+ # wrap commands if that is configured
279
+ cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil?
280
+
281
+ if @transport_options[:pty]
282
+ channel.request_pty do |_ch, success|
283
+ fail Train::Transports::SSHPTYFailed, 'Requesting PTY failed' unless success
284
+ end
285
+ end
286
+ channel.exec(cmd) do |_, success|
287
+ abort 'Couldn\'t execute command on SSH.' unless success
288
+ channel.on_data do |_, data|
289
+ yield(data) unless data_handler.nil?
290
+ stdout += data
291
+ end
292
+
293
+ channel.on_extended_data do |_, _type, data|
294
+ yield(data) unless data_handler.nil?
295
+ stderr += data
296
+ end
297
+
298
+ channel.on_request('exit-status') do |_, data|
299
+ exit_status = data.read_long
300
+ end
301
+
302
+ channel.on_request('exit-signal') do |_, data|
303
+ exit_status = data.read_long
304
+ end
305
+ end
306
+ end
307
+ session.loop
308
+ [exit_status, stdout, stderr]
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,207 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Salim Afiune (<salim@afiunemaya.com.mx>)
4
+ # Author:: Matt Wrock (<matt@mattwrock.com>)
5
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
6
+ # Author:: Dominik Richter (<dominik.richter@gmail.com>)
7
+ # Author:: Christoph Hartmann (<chris@lollyrock.com>)
8
+ #
9
+ # Copyright (C) 2014, Salim Afiune
10
+ #
11
+ # Licensed under the Apache License, Version 2.0 (the "License");
12
+ # you may not use this file except in compliance with the License.
13
+ # You may obtain a copy of the License at
14
+ #
15
+ # http://www.apache.org/licenses/LICENSE-2.0
16
+ #
17
+ # Unless required by applicable law or agreed to in writing, software
18
+ # distributed under the License is distributed on an "AS IS" BASIS,
19
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20
+ # See the License for the specific language governing permissions and
21
+ # limitations under the License.
22
+
23
+ require 'rbconfig'
24
+ require 'uri'
25
+ require 'train/errors'
26
+
27
+ module Train::Transports
28
+ # Wrapped exception for any internally raised WinRM-related errors.
29
+ #
30
+ # @author Fletcher Nichol <fnichol@nichol.ca>
31
+ class WinRMFailed < Train::TransportError; end
32
+
33
+ # A Transport which uses WinRM to execute commands and transfer files.
34
+ #
35
+ # @author Matt Wrock <matt@mattwrock.com>
36
+ # @author Salim Afiune <salim@afiunemaya.com.mx>
37
+ # @author Fletcher Nichol <fnichol@nichol.ca>
38
+ class WinRM < Train.plugin(1) # rubocop:disable ClassLength
39
+ name 'winrm'
40
+
41
+ require 'train/transports/winrm_connection'
42
+
43
+ # ref: https://github.com/winrb/winrm#transports
44
+ SUPPORTED_WINRM_TRANSPORTS = %i(negotiate ssl plaintext kerberos).freeze
45
+
46
+ # common target configuration
47
+ option :host, required: true
48
+ option :port
49
+ option :user, default: 'administrator', required: true
50
+ option :password, nil
51
+ option :winrm_transport, default: :negotiate
52
+ option :winrm_disable_sspi, default: false
53
+ option :winrm_basic_auth_only, default: false
54
+ option :path, default: '/wsman'
55
+ option :ssl, default: false
56
+ option :self_signed, default: false
57
+
58
+ # additional winrm options
59
+ option :rdp_port, default: 3389
60
+ option :connection_retries, default: 5
61
+ option :connection_retry_sleep, default: 1
62
+ option :max_wait_until_ready, default: 600
63
+ option :ssl_peer_fingerprint, default: nil
64
+ option :kerberos_realm, default: nil
65
+ option :kerberos_service, default: nil
66
+ option :ca_trust_file, default: nil
67
+ # The amount of time in SECONDS for which each operation must get an ack
68
+ # from the winrm endpoint. Does not mean that the command has
69
+ # completed in this time, only that the server has ack'd the request.
70
+ option :operation_timeout, default: nil
71
+
72
+ def initialize(opts)
73
+ super(opts)
74
+ load_needed_dependencies!
75
+ end
76
+
77
+ # (see Base#connection)
78
+ def connection(state = nil, &block)
79
+ opts = merge_options(options, state || {})
80
+ validate_options(opts)
81
+ conn_opts = connection_options(opts)
82
+
83
+ if @connection && @connection_options == conn_opts
84
+ reuse_connection(&block)
85
+ else
86
+ create_new_connection(conn_opts, &block)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def validate_options(opts)
93
+ super(opts)
94
+
95
+ # set scheme and port based on ssl activation
96
+ scheme = opts[:ssl] ? 'https' : 'http'
97
+ port = opts[:port]
98
+ port = (opts[:ssl] ? 5986 : 5985) if port.nil?
99
+ winrm_transport = opts[:winrm_transport].to_sym
100
+ unless SUPPORTED_WINRM_TRANSPORTS.include?(winrm_transport)
101
+ fail Train::ClientError, "Unsupported transport type: #{winrm_transport.inspect}"
102
+ end
103
+
104
+ # remove leading '/'
105
+ path = (opts[:path] || '').sub(%r{^/+}, '')
106
+
107
+ opts[:endpoint] = "#{scheme}://#{opts[:host]}:#{port}/#{path}"
108
+ end
109
+
110
+ WINRM_FS_SPEC_VERSION = '~> 1.0'.freeze
111
+
112
+ # Builds the hash of options needed by the Connection object on
113
+ # construction.
114
+ #
115
+ # @param data [Hash] merged configuration and mutable state data
116
+ # @return [Hash] hash of connection options
117
+ # @api private
118
+ def connection_options(opts)
119
+ {
120
+ logger: logger,
121
+ transport: opts[:winrm_transport].to_sym,
122
+ disable_sspi: opts[:winrm_disable_sspi],
123
+ basic_auth_only: opts[:winrm_basic_auth_only],
124
+ hostname: opts[:host],
125
+ endpoint: opts[:endpoint],
126
+ user: opts[:user],
127
+ password: opts[:password],
128
+ rdp_port: opts[:rdp_port],
129
+ connection_retries: opts[:connection_retries],
130
+ connection_retry_sleep: opts[:connection_retry_sleep],
131
+ max_wait_until_ready: opts[:max_wait_until_ready],
132
+ no_ssl_peer_verification: opts[:self_signed],
133
+ realm: opts[:kerberos_realm],
134
+ service: opts[:kerberos_service],
135
+ ca_trust_file: opts[:ca_trust_file],
136
+ ssl_peer_fingerprint: opts[:ssl_peer_fingerprint],
137
+ }
138
+ end
139
+
140
+ # Creates a new WinRM Connection instance and save it for potential
141
+ # future reuse.
142
+ #
143
+ # @param options [Hash] conneciton options
144
+ # @return [WinRM::Connection] a WinRM Connection instance
145
+ # @api private
146
+ def create_new_connection(options, &block)
147
+ if @connection
148
+ logger.debug("[WinRM] shutting previous connection #{@connection}")
149
+ @connection.close
150
+ end
151
+
152
+ @connection_options = options
153
+ @connection = Connection.new(options, &block)
154
+ end
155
+
156
+ # (see Base#load_needed_dependencies!)
157
+ def load_needed_dependencies!
158
+ spec_version = WINRM_FS_SPEC_VERSION.dup
159
+ logger.debug('winrm-fs requested,' \
160
+ " loading WinRM::FS gem (#{spec_version})")
161
+ gem 'winrm-fs', spec_version
162
+ first_load = require 'winrm-fs'
163
+ load_winrm_transport!
164
+
165
+ if first_load
166
+ logger.debug('WinRM::FS library loaded')
167
+ else
168
+ logger.debug('WinRM::FS previously loaded')
169
+ end
170
+ rescue LoadError => e
171
+ logger.fatal(
172
+ "The `winrm-fs' gem is missing and must" \
173
+ ' be installed or cannot be properly activated. Run' \
174
+ " `gem install winrm-fs --version '#{spec_version}'`" \
175
+ ' or add the following to your Gemfile if you are using Bundler:' \
176
+ " `gem 'winrm-fs', '#{spec_version}'`.",
177
+ )
178
+ raise Train::UserError,
179
+ "Could not load or activate WinRM::FS (#{e.message})"
180
+ end
181
+
182
+ # Load WinRM::Transport code.
183
+ #
184
+ # @api private
185
+ def load_winrm_transport!
186
+ silence_warnings { require 'winrm-fs' }
187
+ end
188
+
189
+ # Return the last saved WinRM connection instance.
190
+ #
191
+ # @return [Winrm::Connection] a WinRM Connection instance
192
+ # @api private
193
+ def reuse_connection
194
+ logger.debug("[WinRM] reusing existing connection #{@connection}")
195
+ yield @connection if block_given?
196
+ @connection
197
+ end
198
+
199
+ def silence_warnings
200
+ old_verbose = $VERBOSE
201
+ $VERBOSE = nil
202
+ yield
203
+ ensure
204
+ $VERBOSE = old_verbose
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,200 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Salim Afiune (<salim@afiunemaya.com.mx>)
4
+ # Author:: Matt Wrock (<matt@mattwrock.com>)
5
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
6
+ # Author:: Dominik Richter (<dominik.richter@gmail.com>)
7
+ # Author:: Christoph Hartmann (<chris@lollyrock.com>)
8
+ #
9
+ # Copyright (C) 2014, Salim Afiune
10
+ #
11
+ # Licensed under the Apache License, Version 2.0 (the "License");
12
+ # you may not use this file except in compliance with the License.
13
+ # You may obtain a copy of the License at
14
+ #
15
+ # http://www.apache.org/licenses/LICENSE-2.0
16
+ #
17
+ # Unless required by applicable law or agreed to in writing, software
18
+ # distributed under the License is distributed on an "AS IS" BASIS,
19
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20
+ # See the License for the specific language governing permissions and
21
+ # limitations under the License.
22
+
23
+ class Train::Transports::WinRM
24
+ # A Connection instance can be generated and re-generated, given new
25
+ # connection details such as connection port, hostname, credentials, etc.
26
+ # This object is responsible for carrying out the actions on the remote
27
+ # host such as executing commands, transferring files, etc.
28
+ #
29
+ # @author Fletcher Nichol <fnichol@nichol.ca>
30
+ class Connection < BaseConnection # rubocop:disable Metrics/ClassLength
31
+ attr_reader :hostname
32
+ def initialize(options)
33
+ super(options)
34
+ @hostname = @options.delete(:hostname)
35
+ @rdp_port = @options.delete(:rdp_port)
36
+ @connection_retries = @options.delete(:connection_retries)
37
+ @connection_retry_sleep = @options.delete(:connection_retry_sleep)
38
+ @max_wait_until_ready = @options.delete(:max_wait_until_ready)
39
+ @operation_timeout = @options.delete(:operation_timeout)
40
+ end
41
+
42
+ # (see Base::Connection#close)
43
+ def close
44
+ return if @session.nil?
45
+ session.close
46
+ ensure
47
+ @session = nil
48
+ end
49
+
50
+ # (see Base::Connection#login_command)
51
+ def login_command
52
+ case RbConfig::CONFIG['host_os']
53
+ when /darwin/
54
+ login_command_for_mac
55
+ when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
56
+ login_command_for_windows
57
+ when /linux/
58
+ login_command_for_linux
59
+ else
60
+ fail ActionFailed,
61
+ "Remote login not supported in #{self.class} " \
62
+ "from host OS '#{RbConfig::CONFIG['host_os']}'."
63
+ end
64
+ end
65
+
66
+ # (see Base::Connection#upload)
67
+ def upload(locals, remote)
68
+ file_manager.upload(locals, remote)
69
+ end
70
+
71
+ def download(remotes, local)
72
+ Array(remotes).each do |remote|
73
+ file_manager.download(remote, local)
74
+ end
75
+ end
76
+
77
+ # (see Base::Connection#wait_until_ready)
78
+ def wait_until_ready
79
+ delay = 3
80
+ session(
81
+ retry_limit: @max_wait_until_ready / delay,
82
+ retry_delay: delay,
83
+ )
84
+ run_command_via_connection(PING_COMMAND.dup)
85
+ end
86
+
87
+ def uri
88
+ "winrm://#{options[:user]}@#{options[:endpoint]}:#{@rdp_port}"
89
+ end
90
+
91
+ private
92
+
93
+ PING_COMMAND = "Write-Host '[WinRM] Established\n'".freeze
94
+
95
+ def file_via_connection(path)
96
+ Train::File::Remote::Windows.new(self, path)
97
+ end
98
+
99
+ def run_command_via_connection(command, &data_handler)
100
+ return if command.nil?
101
+ logger.debug("[WinRM] #{self} (#{command})")
102
+ out = ''
103
+
104
+ response = session.run(command) do |stdout, _|
105
+ yield(stdout) if data_handler && stdout
106
+ out << stdout if stdout
107
+ end
108
+
109
+ CommandResult.new(out, response.stderr, response.exitcode)
110
+ end
111
+
112
+ # Create a local RDP document and return it
113
+ #
114
+ # @param opts [Hash] configuration options
115
+ # @option opts [true,false] :mac whether or not the document is for a
116
+ # Mac system
117
+ # @api private
118
+ def rdp_doc(opts = {})
119
+ host = URI.parse(options[:endpoint]).host
120
+ content = [
121
+ "full address:s:#{host}:#{@rdp_port}",
122
+ 'prompt for credentials:i:1',
123
+ "username:s:#{options[:user]}",
124
+ ].join("\n")
125
+
126
+ content.prepend("drivestoredirect:s:*\n") if opts[:mac]
127
+
128
+ content
129
+ end
130
+
131
+ # @return [Winrm::FileManager] a file transporter
132
+ # @api private
133
+ def file_manager
134
+ @file_manager ||= begin
135
+ # Ensure @service is available:
136
+ wait_until_ready
137
+ WinRM::FS::FileManager.new(@service)
138
+ end
139
+ end
140
+
141
+ # Builds a `LoginCommand` for use by Linux-based platforms.
142
+ #
143
+ # TODO: determine whether or not `desktop` exists
144
+ #
145
+ # @return [LoginCommand] a login command
146
+ # @api private
147
+ def login_command_for_linux
148
+ args = %W( -u #{options[:user]} )
149
+ args += %W( -p #{options[:pass]} ) if options.key?(:pass)
150
+ args += %W( #{URI.parse(options[:endpoint]).host}:#{@rdp_port} )
151
+ LoginCommand.new('rdesktop', args)
152
+ end
153
+
154
+ # Builds a `LoginCommand` for use by Mac-based platforms.
155
+ #
156
+ # @return [LoginCommand] a login command
157
+ # @api private
158
+ def login_command_for_mac
159
+ LoginCommand.new('open', rdp_doc(mac: true))
160
+ end
161
+
162
+ # Builds a `LoginCommand` for use by Windows-based platforms.
163
+ #
164
+ # @return [LoginCommand] a login command
165
+ # @api private
166
+ def login_command_for_windows
167
+ LoginCommand.new('mstsc', rdp_doc)
168
+ end
169
+
170
+ # Establishes a remote shell session, or establishes one when invoked
171
+ # the first time.
172
+ #
173
+ # @param retry_options [Hash] retry options for the initial connection
174
+ # @return [Winrm::CommandExecutor] the command executor session
175
+ # @api private
176
+ def session(retry_options = {})
177
+ @session ||= begin
178
+ opts = {
179
+ retry_limit: @connection_retries.to_i,
180
+ retry_delay: @connection_retry_sleep.to_i,
181
+ }.merge(retry_options)
182
+
183
+ opts[:operation_timeout] = @operation_timeout unless @operation_timeout.nil?
184
+ @service = ::WinRM::Connection.new(options.merge(opts))
185
+ @service.logger = logger
186
+ @service.shell(:powershell)
187
+ end
188
+ end
189
+
190
+ # String representation of object, reporting its connection details and
191
+ # configuration.
192
+ #
193
+ # @api private
194
+ def to_s
195
+ options_to_print = @options.clone
196
+ options_to_print[:password] = '<hidden>' if options_to_print.key?(:password)
197
+ "#{@username}@#{@hostname}<#{options_to_print.inspect}>"
198
+ end
199
+ end
200
+ end
data/lib/train/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # Author:: Dominik Richter (<dominik.richter@gmail.com>)
4
4
 
5
5
  module Train
6
- VERSION = '2.0.5'.freeze
6
+ VERSION = '2.0.8'.freeze
7
7
  end
metadata CHANGED
@@ -1,15 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: train-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.5
4
+ version: 2.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dominik Richter
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-04-10 00:00:00.000000000 Z
11
+ date: 2019-04-19 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.8'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '1.8'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
13
33
  - !ruby/object:Gem::Dependency
14
34
  name: mixlib-shellout
15
35
  requirement: !ruby/object:Gem::Requirement
@@ -31,12 +51,32 @@ dependencies:
31
51
  - !ruby/object:Gem::Version
32
52
  version: '4.0'
33
53
  - !ruby/object:Gem::Dependency
34
- name: json
54
+ name: net-ssh
35
55
  requirement: !ruby/object:Gem::Requirement
36
56
  requirements:
37
57
  - - ">="
38
58
  - !ruby/object:Gem::Version
39
- version: '1.8'
59
+ version: '2.9'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '6.0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '2.9'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '6.0'
73
+ - !ruby/object:Gem::Dependency
74
+ name: net-scp
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '1.2'
40
80
  - - "<"
41
81
  - !ruby/object:Gem::Version
42
82
  version: '3.0'
@@ -46,11 +86,67 @@ dependencies:
46
86
  requirements:
47
87
  - - ">="
48
88
  - !ruby/object:Gem::Version
49
- version: '1.8'
89
+ version: '1.2'
50
90
  - - "<"
51
91
  - !ruby/object:Gem::Version
52
92
  version: '3.0'
53
- description: A minimal Train with a selected set of backends, ssh, winrm, and docker.
93
+ - !ruby/object:Gem::Dependency
94
+ name: ed25519
95
+ requirement: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '1.2'
100
+ type: :runtime
101
+ prerelease: false
102
+ version_requirements: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - "~>"
105
+ - !ruby/object:Gem::Version
106
+ version: '1.2'
107
+ - !ruby/object:Gem::Dependency
108
+ name: bcrypt_pbkdf
109
+ requirement: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - "~>"
112
+ - !ruby/object:Gem::Version
113
+ version: '1.0'
114
+ type: :runtime
115
+ prerelease: false
116
+ version_requirements: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - "~>"
119
+ - !ruby/object:Gem::Version
120
+ version: '1.0'
121
+ - !ruby/object:Gem::Dependency
122
+ name: winrm
123
+ requirement: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - "~>"
126
+ - !ruby/object:Gem::Version
127
+ version: '2.0'
128
+ type: :runtime
129
+ prerelease: false
130
+ version_requirements: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - "~>"
133
+ - !ruby/object:Gem::Version
134
+ version: '2.0'
135
+ - !ruby/object:Gem::Dependency
136
+ name: winrm-fs
137
+ requirement: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - "~>"
140
+ - !ruby/object:Gem::Version
141
+ version: '1.0'
142
+ type: :runtime
143
+ prerelease: false
144
+ version_requirements: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - "~>"
147
+ - !ruby/object:Gem::Version
148
+ version: '1.0'
149
+ description: A minimal Train with a backends for ssh and winrm.
54
150
  email:
55
151
  - drichter@chef.io
56
152
  executables: []
@@ -93,6 +189,10 @@ files:
93
189
  - lib/train/plugins/transport.rb
94
190
  - lib/train/transports/local.rb
95
191
  - lib/train/transports/mock.rb
192
+ - lib/train/transports/ssh.rb
193
+ - lib/train/transports/ssh_connection.rb
194
+ - lib/train/transports/winrm.rb
195
+ - lib/train/transports/winrm_connection.rb
96
196
  - lib/train/version.rb
97
197
  homepage: https://github.com/inspec/train/
98
198
  licenses: