bolt 0.16.1 → 0.16.2

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of bolt might be problematic. Click here for more details.

@@ -0,0 +1,135 @@
1
+ require 'bolt/node/errors'
2
+ require 'bolt/transport/base'
3
+ require 'json'
4
+ require 'shellwords'
5
+
6
+ module Bolt
7
+ module Transport
8
+ class SSH < Base
9
+ STDIN_METHODS = %w[both stdin].freeze
10
+ ENVIRONMENT_METHODS = %w[both environment].freeze
11
+
12
+ def initialize(_config)
13
+ super
14
+
15
+ require 'net/ssh'
16
+ require 'net/scp'
17
+ begin
18
+ require 'net/ssh/krb'
19
+ rescue LoadError
20
+ logger.debug {
21
+ "Authentication method 'gssapi-with-mic' is not available"
22
+ }
23
+ end
24
+ end
25
+
26
+ def with_connection(target)
27
+ conn = Connection.new(target)
28
+ conn.connect
29
+ yield conn
30
+ ensure
31
+ begin
32
+ conn.disconnect if conn
33
+ rescue StandardError => ex
34
+ logger.info("Failed to close connection to #{target.uri} : #{ex.message}")
35
+ end
36
+ end
37
+
38
+ def upload(target, source, destination, options = {})
39
+ with_connection(target) do |conn|
40
+ conn.running_as(options['_run_as']) do
41
+ conn.with_remote_tempdir do |dir|
42
+ basename = File.basename(destination)
43
+ tmpfile = "#{dir}/#{basename}"
44
+ conn.write_remote_file(source, tmpfile)
45
+ # pass over file ownership if we're using run-as to be a different user
46
+ dir.chown(conn.run_as)
47
+ result = conn.execute("mv '#{tmpfile}' '#{destination}'", sudoable: true)
48
+ if result.exit_code != 0
49
+ message = "Could not move temporary file '#{tmpfile}' to #{destination}: #{result.stderr.string}"
50
+ raise Bolt::Node::FileError.new(message, 'MV_ERROR')
51
+ end
52
+ end
53
+ Bolt::Result.for_upload(target, source, destination)
54
+ end
55
+ end
56
+ end
57
+
58
+ def run_command(target, command, options = {})
59
+ with_connection(target) do |conn|
60
+ conn.running_as(options['_run_as']) do
61
+ output = conn.execute(command, sudoable: true)
62
+ Bolt::Result.for_command(target, output.stdout.string, output.stderr.string, output.exit_code)
63
+ end
64
+ end
65
+ end
66
+
67
+ def run_script(target, script, arguments, options = {})
68
+ with_connection(target) do |conn|
69
+ conn.running_as(options['_run_as']) do
70
+ conn.with_remote_tempdir do |dir|
71
+ remote_path = conn.write_remote_executable(dir, script)
72
+ dir.chown(conn.run_as)
73
+ output = conn.execute("'#{remote_path}' #{Shellwords.join(arguments)}", sudoable: true)
74
+ Bolt::Result.for_command(target, output.stdout.string, output.stderr.string, output.exit_code)
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def run_task(target, task, arguments, options = {})
81
+ input_method = task.input_method
82
+ with_connection(target) do |conn|
83
+ conn.running_as(options['_run_as']) do
84
+ export_args = {}
85
+ stdin, output = nil
86
+
87
+ if STDIN_METHODS.include?(input_method)
88
+ stdin = JSON.dump(arguments)
89
+ end
90
+
91
+ if ENVIRONMENT_METHODS.include?(input_method)
92
+ export_args = arguments.map do |env, val|
93
+ "PT_#{env}='#{val}'"
94
+ end.join(' ')
95
+ end
96
+
97
+ command = export_args.empty? ? '' : "#{export_args} "
98
+
99
+ execute_options = {}
100
+
101
+ conn.with_remote_tempdir do |dir|
102
+ remote_task_path = conn.write_remote_executable(dir, task.executable)
103
+ if conn.run_as && stdin
104
+ wrapper = make_wrapper_stringio(remote_task_path, stdin)
105
+ remote_wrapper_path = conn.write_remote_executable(dir, wrapper, 'wrapper.sh')
106
+ command += "'#{remote_wrapper_path}'"
107
+ else
108
+ command += "'#{remote_task_path}'"
109
+ execute_options[:stdin] = stdin
110
+ end
111
+ dir.chown(conn.run_as)
112
+
113
+ execute_options[:sudoable] = true if conn.run_as
114
+ output = conn.execute(command, **execute_options)
115
+ end
116
+ Bolt::Result.for_task(target, output.stdout.string,
117
+ output.stderr.string,
118
+ output.exit_code)
119
+ end
120
+ end
121
+ end
122
+
123
+ def make_wrapper_stringio(task_path, stdin)
124
+ StringIO.new(<<-SCRIPT)
125
+ #!/bin/sh
126
+ '#{task_path}' <<EOF
127
+ #{stdin}
128
+ EOF
129
+ SCRIPT
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ require 'bolt/transport/ssh/connection'
@@ -0,0 +1,278 @@
1
+ require 'logging'
2
+ require 'bolt/node/errors'
3
+ require 'bolt/node/output'
4
+
5
+ module Bolt
6
+ module Transport
7
+ class SSH < Base
8
+ class Connection
9
+ class RemoteTempdir
10
+ def initialize(node, path)
11
+ @node = node
12
+ @owner = node.user
13
+ @path = path
14
+ @logger = node.logger
15
+ end
16
+
17
+ def to_s
18
+ @path
19
+ end
20
+
21
+ def chown(owner)
22
+ return if owner.nil? || owner == @owner
23
+
24
+ @owner = owner
25
+ result = @node.execute("chown -R '#{@owner}': '#{@path}'", sudoable: true, run_as: 'root')
26
+ if result.exit_code != 0
27
+ message = "Could not change owner of '#{@path}' to #{@owner}: #{result.stderr.string}"
28
+ raise Bolt::Node::FileError.new(message, 'CHOWN_ERROR')
29
+ end
30
+ end
31
+
32
+ def delete
33
+ result = @node.execute("rm -rf '#{@path}'", sudoable: true, run_as: @owner)
34
+ if result.exit_code != 0
35
+ @logger.warn("Failed to clean up tempdir '#{@path}': #{result.stderr.string}")
36
+ end
37
+ end
38
+ end
39
+
40
+ attr_reader :logger, :user, :target
41
+ attr_writer :run_as
42
+
43
+ def initialize(target)
44
+ @target = target
45
+
46
+ @user = @target.user || Net::SSH::Config.for(target.host)[:user] || Etc.getlogin
47
+ @run_as = nil
48
+
49
+ @logger = Logging.logger[@target.host]
50
+ end
51
+
52
+ if !!File::ALT_SEPARATOR
53
+ require 'ffi'
54
+ module Win
55
+ extend FFI::Library
56
+ ffi_lib 'user32'
57
+ ffi_convention :stdcall
58
+ attach_function :FindWindow, :FindWindowW, %i[buffer_in buffer_in], :int
59
+ end
60
+ end
61
+
62
+ def connect
63
+ transport_logger = Logging.logger[Net::SSH]
64
+ transport_logger.level = :warn
65
+ options = {
66
+ logger: transport_logger,
67
+ non_interactive: true
68
+ }
69
+
70
+ options[:port] = target.port if target.port
71
+ options[:password] = target.password if target.password
72
+ options[:keys] = target.options[:key] if target.options[:key]
73
+ options[:verify_host_key] = if target.options[:host_key_check]
74
+ Net::SSH::Verifiers::Secure.new
75
+ else
76
+ Net::SSH::Verifiers::Lenient.new
77
+ end
78
+ options[:timeout] = target.options[:connect_timeout] if target.options[:connect_timeout]
79
+
80
+ # Mirroring:
81
+ # https://github.com/net-ssh/net-ssh/blob/master/lib/net/ssh/authentication/agent.rb#L80
82
+ # https://github.com/net-ssh/net-ssh/blob/master/lib/net/ssh/authentication/pageant.rb#L403
83
+ if defined?(UNIXSocket) && UNIXSocket
84
+ if ENV['SSH_AUTH_SOCK'].to_s.empty?
85
+ @logger.debug { "Disabling use_agent in net-ssh: ssh-agent is not available" }
86
+ options[:use_agent] = false
87
+ end
88
+ elsif !!File::ALT_SEPARATOR
89
+ pageant_wide = 'Pageant'.encode('UTF-16LE')
90
+ if Win.FindWindow(pageant_wide, pageant_wide).to_i == 0
91
+ @logger.debug { "Disabling use_agent in net-ssh: pageant process not running" }
92
+ options[:use_agent] = false
93
+ end
94
+ end
95
+
96
+ @session = Net::SSH.start(target.host, @user, options)
97
+ @logger.debug { "Opened session" }
98
+ rescue Net::SSH::AuthenticationFailed => e
99
+ raise Bolt::Node::ConnectError.new(
100
+ e.message,
101
+ 'AUTH_ERROR'
102
+ )
103
+ rescue Net::SSH::HostKeyError => e
104
+ raise Bolt::Node::ConnectError.new(
105
+ "Host key verification failed for #{target.uri}: #{e.message}",
106
+ 'HOST_KEY_ERROR'
107
+ )
108
+ rescue Net::SSH::ConnectionTimeout
109
+ raise Bolt::Node::ConnectError.new(
110
+ "Timeout after #{target.options[:connect_timeout]} seconds connecting to #{target.uri}",
111
+ 'CONNECT_ERROR'
112
+ )
113
+ rescue StandardError => e
114
+ raise Bolt::Node::ConnectError.new(
115
+ "Failed to connect to #{target.uri}: #{e.message}",
116
+ 'CONNECT_ERROR'
117
+ )
118
+ end
119
+
120
+ def disconnect
121
+ if @session && !@session.closed?
122
+ @session.close
123
+ @logger.debug { "Closed session" }
124
+ end
125
+ end
126
+
127
+ # This method allows the @run_as variable to be used as a per-operation
128
+ # override for the user to run as. When @run_as is unset, the user
129
+ # specified on the target will be used.
130
+ def run_as
131
+ @run_as || target.options[:run_as]
132
+ end
133
+
134
+ # Run as the specified user for the duration of the block.
135
+ def running_as(user)
136
+ @run_as = user
137
+ yield
138
+ ensure
139
+ @run_as = nil
140
+ end
141
+
142
+ def sudo_prompt
143
+ '[sudo] Bolt needs to run as another user, password: '
144
+ end
145
+
146
+ def handled_sudo(channel, data)
147
+ if data.lines.include?(sudo_prompt)
148
+ if target.options[:sudo_password]
149
+ channel.send_data "#{target.options[:sudo_password]}\n"
150
+ channel.wait
151
+ return true
152
+ else
153
+ raise Bolt::Node::EscalateError.new(
154
+ "Sudo password for user #{@user} was not provided for #{target.uri}",
155
+ 'NO_PASSWORD'
156
+ )
157
+ end
158
+ elsif data =~ /^#{@user} is not in the sudoers file\./
159
+ @logger.debug { data }
160
+ raise Bolt::Node::EscalateError.new(
161
+ "User #{@user} does not have sudo permission on #{target.uri}",
162
+ 'SUDO_DENIED'
163
+ )
164
+ elsif data =~ /^Sorry, try again\./
165
+ @logger.debug { data }
166
+ raise Bolt::Node::EscalateError.new(
167
+ "Sudo password for user #{@user} not recognized on #{target.uri}",
168
+ 'BAD_PASSWORD'
169
+ )
170
+ end
171
+ false
172
+ end
173
+
174
+ def execute(command, sudoable: false, **options)
175
+ result_output = Bolt::Node::Output.new
176
+ run_as = options[:run_as] || self.run_as
177
+ use_sudo = sudoable && run_as && @user != run_as
178
+ if use_sudo
179
+ command = "sudo -S -u #{run_as} -p '#{sudo_prompt}' #{command}"
180
+ end
181
+
182
+ @logger.debug { "Executing: #{command}" }
183
+
184
+ session_channel = @session.open_channel do |channel|
185
+ # Request a pseudo tty
186
+ channel.request_pty if target.options[:tty]
187
+
188
+ channel.exec(command) do |_, success|
189
+ unless success
190
+ raise Bolt::Node::ConnectError.new(
191
+ "Could not execute command: #{command.inspect}",
192
+ 'EXEC_ERROR'
193
+ )
194
+ end
195
+
196
+ channel.on_data do |_, data|
197
+ unless use_sudo && handled_sudo(channel, data)
198
+ result_output.stdout << data
199
+ end
200
+ @logger.debug { "stdout: #{data}" }
201
+ end
202
+
203
+ channel.on_extended_data do |_, _, data|
204
+ unless use_sudo && handled_sudo(channel, data)
205
+ result_output.stderr << data
206
+ end
207
+ @logger.debug { "stderr: #{data}" }
208
+ end
209
+
210
+ channel.on_request("exit-status") do |_, data|
211
+ result_output.exit_code = data.read_long
212
+ end
213
+
214
+ if options[:stdin]
215
+ channel.send_data(options[:stdin])
216
+ channel.eof!
217
+ end
218
+ end
219
+ end
220
+ session_channel.wait
221
+
222
+ if result_output.exit_code == 0
223
+ @logger.debug { "Command returned successfully" }
224
+ else
225
+ @logger.info { "Command failed with exit code #{result_output.exit_code}" }
226
+ end
227
+ result_output
228
+ end
229
+
230
+ def write_remote_file(source, destination)
231
+ @session.scp.upload!(source, destination)
232
+ rescue StandardError => e
233
+ raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
234
+ end
235
+
236
+ def make_tempdir
237
+ if target.options[:tmpdir]
238
+ tmppath = "#{target.options[:tmpdir]}/#{SecureRandom.uuid}"
239
+ command = "mkdir -m 700 #{tmppath}"
240
+ else
241
+ command = 'mktemp -d'
242
+ end
243
+ result = execute(command)
244
+ if result.exit_code != 0
245
+ raise Bolt::Node::FileError.new("Could not make tempdir: #{result.stderr.string}", 'TEMPDIR_ERROR')
246
+ end
247
+ path = tmppath || result.stdout.string.chomp
248
+ RemoteTempdir.new(self, path)
249
+ end
250
+
251
+ # A helper to create and delete a tempdir on the remote system. Yields the
252
+ # directory name.
253
+ def with_remote_tempdir
254
+ dir = make_tempdir
255
+ yield dir
256
+ ensure
257
+ dir.delete if dir
258
+ end
259
+
260
+ def write_remote_executable(dir, file, filename = nil)
261
+ filename ||= File.basename(file)
262
+ remote_path = "#{dir}/#{filename}"
263
+ write_remote_file(file, remote_path)
264
+ make_executable(remote_path)
265
+ remote_path
266
+ end
267
+
268
+ def make_executable(path)
269
+ result = execute("chmod u+x '#{path}'")
270
+ if result.exit_code != 0
271
+ message = "Could not make file '#{path}' executable: #{result.stderr.string}"
272
+ raise Bolt::Node::FileError.new(message, 'CHMOD_ERROR')
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,165 @@
1
+ require 'bolt/transport/base'
2
+
3
+ module Bolt
4
+ module Transport
5
+ class WinRM < Base
6
+ STDIN_METHODS = %w[both stdin].freeze
7
+ ENVIRONMENT_METHODS = %w[both environment].freeze
8
+
9
+ PS_ARGS = %w[
10
+ -NoProfile -NonInteractive -NoLogo -ExecutionPolicy Bypass
11
+ ].freeze
12
+
13
+ def initialize(_config)
14
+ super
15
+ require 'winrm'
16
+ require 'winrm-fs'
17
+ end
18
+
19
+ def with_connection(target)
20
+ conn = Connection.new(target)
21
+ conn.connect
22
+ yield conn
23
+ ensure
24
+ begin
25
+ conn.disconnect if conn
26
+ rescue StandardError => ex
27
+ logger.info("Failed to close connection to #{target.uri} : #{ex.message}")
28
+ end
29
+ end
30
+
31
+ def upload(target, source, destination, _options = {})
32
+ with_connection(target) do |conn|
33
+ conn.write_remote_file(source, destination)
34
+ Bolt::Result.for_upload(target, source, destination)
35
+ end
36
+ end
37
+
38
+ def run_command(target, command, _options = {})
39
+ with_connection(target) do |conn|
40
+ output = conn.execute(command)
41
+ Bolt::Result.for_command(target, output.stdout.string, output.stderr.string, output.exit_code)
42
+ end
43
+ end
44
+
45
+ def run_script(target, script, arguments, _options = {})
46
+ with_connection(target) do |conn|
47
+ conn.with_remote_file(script) do |remote_path|
48
+ if powershell_file?(remote_path)
49
+ mapped_args = arguments.map do |a|
50
+ "$invokeArgs.ArgumentList += @'\n#{a}\n'@"
51
+ end.join("\n")
52
+ output = conn.execute(<<-PS)
53
+ $invokeArgs = @{
54
+ ScriptBlock = (Get-Command "#{remote_path}").ScriptBlock
55
+ ArgumentList = @()
56
+ }
57
+ #{mapped_args}
58
+
59
+ try
60
+ {
61
+ Invoke-Command @invokeArgs
62
+ }
63
+ catch
64
+ {
65
+ exit 1
66
+ }
67
+ PS
68
+ else
69
+ path, args = *process_from_extension(remote_path)
70
+ args += escape_arguments(arguments)
71
+ output = conn.execute_process(path, args)
72
+ end
73
+ Bolt::Result.for_command(target, output.stdout.string, output.stderr.string, output.exit_code)
74
+ end
75
+ end
76
+ end
77
+
78
+ def run_task(target, task, arguments, _options = {})
79
+ input_method = task.input_method
80
+ with_connection(target) do |conn|
81
+ if STDIN_METHODS.include?(input_method)
82
+ stdin = JSON.dump(arguments)
83
+ end
84
+
85
+ if ENVIRONMENT_METHODS.include?(input_method)
86
+ arguments.each do |(arg, val)|
87
+ cmd = "[Environment]::SetEnvironmentVariable('PT_#{arg}', '#{val}')"
88
+ result = conn.execute(cmd)
89
+ if result.exit_code != 0
90
+ raise EnvironmentVarError(var, value)
91
+ end
92
+ end
93
+ end
94
+
95
+ conn.with_remote_file(task.executable) do |remote_path|
96
+ output =
97
+ if powershell_file?(remote_path) && stdin.nil?
98
+ # NOTE: cannot redirect STDIN to a .ps1 script inside of PowerShell
99
+ # must create new powershell.exe process like other interpreters
100
+ # fortunately, using PS with stdin input_method should never happen
101
+ if input_method == 'powershell'
102
+ conn.execute(<<-PS)
103
+ $private:taskArgs = Get-ContentAsJson (
104
+ $utf8.GetString([System.Convert]::FromBase64String('#{Base64.encode64(JSON.dump(arguments))}'))
105
+ )
106
+ try { & "#{remote_path}" @taskArgs } catch { exit 1 }
107
+ PS
108
+ else
109
+ conn.execute(%(try { & "#{remote_path}" } catch { exit 1 }))
110
+ end
111
+ else
112
+ path, args = *process_from_extension(remote_path)
113
+ conn.execute_process(path, args, stdin)
114
+ end
115
+ Bolt::Result.for_task(target, output.stdout.string,
116
+ output.stderr.string,
117
+ output.exit_code)
118
+ end
119
+ end
120
+ end
121
+
122
+ def powershell_file?(path)
123
+ Pathname(path).extname.casecmp('.ps1').zero?
124
+ end
125
+
126
+ def process_from_extension(path)
127
+ case Pathname(path).extname.downcase
128
+ when '.rb'
129
+ [
130
+ 'ruby.exe',
131
+ ['-S', "\"#{path}\""]
132
+ ]
133
+ when '.ps1'
134
+ [
135
+ 'powershell.exe',
136
+ [*PS_ARGS, '-File', "\"#{path}\""]
137
+ ]
138
+ when '.pp'
139
+ [
140
+ 'puppet.bat',
141
+ ['apply', "\"#{path}\""]
142
+ ]
143
+ else
144
+ # Run the script via cmd, letting Windows extension handling determine how
145
+ [
146
+ 'cmd.exe',
147
+ ['/c', "\"#{path}\""]
148
+ ]
149
+ end
150
+ end
151
+
152
+ def escape_arguments(arguments)
153
+ arguments.map do |arg|
154
+ if arg =~ / /
155
+ "\"#{arg}\""
156
+ else
157
+ arg
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ require 'bolt/transport/winrm/connection'