train-core 2.0.5 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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: